mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
DEV: Introduce warpdrive store (with compat layer) and migrate some models (#42147)
Adds a WarpDrive store (`service:warp-store`, LegacyMode + JSON:API cache) alongside the existing `service:store`, routing requests through Discourse's `ajax()` helper. `RestCompatModel` bridges legacy `RestModel` callsites (`get`/`set`/`setProperties`, `store.createRecord`, `save`, `destroyRecord`) onto it. Converts **badge, user-badge, topic-details, bookmark, tag, tag-group, tag-info, tag-notification, tag-settings and archetype**, with per-model schemas, request builders and payload normalizers. Attributes outside a schema (plugin `add_to_serializer` fields, ad-hoc `create` keys) are retained separately so nothing the server sends is dropped.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: discourse-warpdrive-models
|
||||
description: Use when creating a new WarpDrive-backed frontend model, or when reading, using, or changing a model already converted to WarpDrive (anything under frontend/discourse/app/data or extending RestCompatModel/WarpRestModel)
|
||||
---
|
||||
|
||||
# WarpDrive models
|
||||
|
||||
`service:warp-store` (`services/warp-store.js`) is a second data store alongside the legacy `service:store`, built on `@warp-drive/*` in LegacyMode with a JSON:API cache. Migrated models live in `frontend/discourse/app/data/`:
|
||||
|
||||
- `schemas/` — one resource schema per type, registered in `schemas/index.js`
|
||||
- `normalize.js` + `jsonapi-utils.js` — Discourse REST payloads → JSON:API documents
|
||||
- `builders/` — request objects, one file per resource
|
||||
- `handlers/discourse-rest.js` — the sole network handler; routes through `ajax()`
|
||||
- `warp-rest-model.js` — `WarpRestModel` wrapper base, plus `warpStore()`, `requestMany`/`requestOne`, `defineFieldForwarders`
|
||||
- `rest-compat.js` — `RestCompatModel`, temporary legacy `RestModel` surface (`get`/`set`/`setProperties`, drafts, legacy adapter save path)
|
||||
- `extra-attributes.js` — retains payload keys no schema declares (migration safety net)
|
||||
|
||||
A model is converted if its class in `app/models/` extends `RestCompatModel` (or `WarpRestModel` directly) and its schema is listed in `schemas/index.js`.
|
||||
|
||||
## Note: Schemas must be complete
|
||||
|
||||
A record is a proxy over the cache; reading a field the schema doesn't declare **throws in dev/test and returns `undefined` in production**. `Object.keys`, `for...in`, and `toJSON` see only schema fields. Therefore:
|
||||
|
||||
- Declare more attributes than seems necessary — a field only one endpoint sends still needs a line.
|
||||
- To find every field, read the Ruby serializers (`app/serializers/`, including subclasses and `add_to_serializer` calls) and the API JSON schemas (`spec/requests/api/schemas/json/`) — not just one sample payload.
|
||||
- Anything still arriving via `extra-attributes.js` is unfinished work, not a pattern to rely on.
|
||||
|
||||
## Creating a new model
|
||||
|
||||
1. **Schema** — `data/schemas/<type>.js`, using `withDefaults` + `attrs`/`belongsTo` from `schemas/helpers.js`. Annotate with `/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */` (avoids a TS2883 d.ts issue). Register it in `schemas/index.js`.
|
||||
2. **Normalizer** — in `data/normalize.js` (or a per-resource file), build `{ data, included, meta }`. Use `resourceFrom(type, Schema, raw)` for the resource object (it also feeds the extras registry; pass an explicit `id` for sub-resources), `indexIncluded` + `maybeRelate` for relationships — `maybeRelate` drops pointers with no matching `included` entry, on purpose. Absent payload → `{ data: null }`, never `{ data: [] }` for single-record ops (wrap with the `recordOnly` pattern).
|
||||
3. **Builders** — `data/builders/<resource>.js` using `readMany`/`readOne`/`createOne`/`updateOne`/`deleteOne` from `builders/helpers.js`; they carry the `op` and the `data: { type, id }` the cache needs. RPC-style endpoints (toggles, bulk ops) are plain inline `{ url, method, options }` objects with no `op`.
|
||||
4. **Model class** — in `app/models/`, extend `RestCompatModel` (only extend `WarpRestModel` directly if no caller uses the legacy store/`get`/`set` API):
|
||||
|
||||
```js
|
||||
export default class Badge extends RestCompatModel {
|
||||
static type = "badge";
|
||||
static normalize = normalizeBadgesPayload;
|
||||
static builders = {
|
||||
list: findBadges,
|
||||
one: findBadge,
|
||||
save: saveBadge,
|
||||
delete: deleteBadge,
|
||||
};
|
||||
// custom getters here take precedence over forwarders
|
||||
}
|
||||
defineFieldForwarders(Badge, BadgeSchema);
|
||||
```
|
||||
|
||||
Other subclass hooks:
|
||||
- endpoints beyond plain CRUD — own statics over `requestMany`/`requestOne` (see `UserBadge.findByUsername`), not more `builders` keys
|
||||
- `static munge(json)` — massages JSON on legacy `_hydrate`
|
||||
- `primaryKey` — `TagNotification` uses `"name"`
|
||||
- `__resource` override, for models with their own ingest path — call `_applyExtraAttributes(id)` after pushing, and read `__ownResource` in anything the base constructor reaches, since subclass fields aren't initialized during `super()`
|
||||
|
||||
5. Run the model's qunit tests plus acceptance tests for its screens.
|
||||
|
||||
Relations to **unmigrated** models (User, Topic, …) are not relationships: store the raw embedded object as a plain attribute (see `bookmark.js` `user`, or `userBadgeResource` inlining sideloads), optionally wrapping it in the legacy model class in a getter or `create`.
|
||||
|
||||
## Working with a converted model
|
||||
|
||||
Reading and mutating:
|
||||
|
||||
- Fields are prototype forwarders to the cached record: `badge.name` reads, `badge.name = x` writes (LegacyMode records are mutable). Relationships are read-only through the wrapper.
|
||||
- Legacy `get("a.b")` / `set` / `setProperties` still work via `RestCompatModel`.
|
||||
- Ids are strings in the cache; the `@id` forwarder (`schema.identity.name`, usually `id`) coerces numeric ones back to numbers. `peekRecord` needs `String(id)`.
|
||||
- Wrap a nested cached resource in its own model class in a getter when callers need that class's getters (`get badge() { return new Badge(this.__resource.badge) }`).
|
||||
- Probe field existence by reading (`record.foo !== undefined`), never with `in` — the `has` trap on draft `trackedObject`s is unreliable and can hang headless qunit.
|
||||
- List results are arrays with document `meta` assigned onto them (`result.grant_count`).
|
||||
|
||||
Fetching and persisting:
|
||||
|
||||
- `Model.findAll(opts)` / `Model.findById(id)` — via `builders.list`/`one`. Anything else: `requestMany(this, someBuilder(...))` / `requestOne(...)`.
|
||||
- `Model.createFromJson(json)` — synchronous ingest for preloaded/embedded payloads; `record.updateFromJson(json)` re-pushes and re-adopts.
|
||||
- Legacy `store.createRecord(type, attrs)` still works: it produces a _draft_ wrapper (`__isLocalDraft`, attrs in a `trackedObject`, undeclared keys readable). After `save()` the wrapper adopts the cached record and strict schema reads apply.
|
||||
- `record.save(data)` has two paths: with `static builders.save` it goes through WarpDrive (`store.request`); without, it falls back to the legacy adapter pipeline. Both fire `addModelCallback` hooks and merge `addModelSaveProperty` extras.
|
||||
- `record.destroy()` (builder path) vs `record.destroyRecord()` (legacy adapter path).
|
||||
- One-off actions: `warpStore().request(someBuilder(...))` with an inline builder. A builder with no normalizer discards the response body (the handler returns `{ data: null }`) — callers needing it use `ajax` directly, as `UserBadge#revoke` does.
|
||||
- Optimistic updates: `store.push({ data: { type, id: String(id), attributes } })`, then `this._adoptResource(this.id)` so a draft wrapper sees the new value; push the previous attributes back if the request rejects (`UserBadge#favorite`).
|
||||
|
||||
Plugin-extension APIs (`api.addModelField`/`Getter`/`Method`/…) still work — fields land as tracked properties on the wrapper, not in the cache. Schema-contributed plugin fields are planned but **not built** — don't design anything that depends on them.
|
||||
|
||||
## Changing a converted model
|
||||
|
||||
- **New server field**: add one line to the schema (`attrs(...)`) — `resourceFrom` picks it up automatically.
|
||||
- **New client-only field**: also declare it in the schema (cheap, and keeps reads legal); mark it with a comment saying who sets it.
|
||||
- **New relationship**: schema `belongsTo` + normalizer `maybeRelate` + push the related resource into `included`. Only between migrated types.
|
||||
- **New endpoint**: add a builder; CRUD shapes use `builders/helpers.js`, RPC shapes are inline objects.
|
||||
- **Derived values**: plain getters on the model class — define them before `defineFieldForwarders` runs. It skips any name that resolves anywhere on the prototype chain, which is also what stops a legacy-derived schema field called `save`/`get`/`destroy` from shadowing a base method.
|
||||
- Prefer data-layer fixes (normalizer, schema, builder, compat layer) over touching consumers (routes, controllers, components) — the migration's goal is unchanged call sites.
|
||||
- Shedding `RestCompatModel` for a model is the end state: only after no caller uses `get`/`set`/`store.createRecord`/legacy `save`.
|
||||
@@ -22,6 +22,7 @@ Discourse is large with long history. Understand context before changes.
|
||||
- Make display strings translatable (use placeholders, not split strings)
|
||||
- Use "Sentence case" for strings, not "Proper Case" or "lower case"
|
||||
- Plugins/themes can't import npm modules directly; add the dependency to core and expose a `frontend/discourse/app/lib/load-*.js` wrapper that does the `import()` (see `load-morphlex.js`).
|
||||
- Use the skill at `.skills/discourse-warpdrive-models` when creating or changing WarpDrive-backed models (`frontend/discourse/app/data`)
|
||||
|
||||
### Comments & Types
|
||||
- Prefer self-documenting code. Comments should only be added when future misunderstanding is likely. They should be terse, and should describe 'why', not 'what'. They should not be used to describe history.
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
arraySortedByProperties,
|
||||
removeValueFromArray,
|
||||
} from "discourse/lib/array-tools";
|
||||
import { grantableBadges } from "discourse/lib/grant-badge-utils";
|
||||
import {
|
||||
grantableBadgeOptions,
|
||||
grantableBadges,
|
||||
} from "discourse/lib/grant-badge-utils";
|
||||
import { autoTrackedArray } from "discourse/lib/tracked-tools";
|
||||
import UserBadge from "discourse/models/user-badge";
|
||||
import { i18n } from "discourse-i18n";
|
||||
@@ -61,6 +64,11 @@ export default class AdminUserBadgesController extends Controller {
|
||||
return grantableBadges(this.allBadges, this.userBadges);
|
||||
}
|
||||
|
||||
@dependentKeyCompat
|
||||
get badgeOptions() {
|
||||
return grantableBadgeOptions(this.availableBadges);
|
||||
}
|
||||
|
||||
get groupedBadges() {
|
||||
const allBadges = this.model;
|
||||
|
||||
@@ -128,7 +136,7 @@ export default class AdminUserBadgesController extends Controller {
|
||||
// Update the selected badge ID after the combobox has re-rendered.
|
||||
const newSelectedBadge = this.availableBadges[0];
|
||||
if (newSelectedBadge) {
|
||||
this.set("selectedBadgeId", newSelectedBadge.get("id"));
|
||||
this.set("selectedBadgeId", newSelectedBadge.id);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class AdminUserBadgesRoute extends DiscourseRoute {
|
||||
if (badges.length > 0) {
|
||||
let grantableBadges = controller.availableBadges;
|
||||
if (grantableBadges.length > 0) {
|
||||
controller.selectedBadgeId = grantableBadges[0].get("id");
|
||||
controller.selectedBadgeId = grantableBadges[0].id;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -44,7 +44,7 @@ export default <template>
|
||||
<label>{{i18n "admin.badges.badge"}}</label>
|
||||
<ComboBox
|
||||
@value={{@controller.selectedBadgeId}}
|
||||
@content={{@controller.availableBadges}}
|
||||
@content={{@controller.badgeOptions}}
|
||||
@onChange={{fn (mut @controller.selectedBadgeId)}}
|
||||
@options={{hash filterable=true}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import "./loader-shims";
|
||||
import "./ui-kit-shims";
|
||||
import "./module-shims";
|
||||
import "./discourse-common-loader-shims";
|
||||
import "@warp-drive/ember/install";
|
||||
import embroiderCompatModules from "@embroider/virtual/compat-modules";
|
||||
import { registerDiscourseImplicitInjections } from "discourse/lib/implicit-injections";
|
||||
import { registerSettings } from "discourse/lib/theme-settings-store";
|
||||
|
||||
@@ -6,6 +6,7 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { extractError } from "discourse/lib/ajax-error";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import {
|
||||
grantableBadgeOptions,
|
||||
grantableBadges,
|
||||
isBadgeGrantable,
|
||||
} from "discourse/lib/grant-badge-utils";
|
||||
@@ -32,6 +33,10 @@ export default class GrantBadgeModal extends Component {
|
||||
!this.availableBadges.length;
|
||||
}
|
||||
|
||||
get badgeOptions() {
|
||||
return grantableBadgeOptions(this.availableBadges);
|
||||
}
|
||||
|
||||
get post() {
|
||||
return this.args.model.selectedPost;
|
||||
}
|
||||
@@ -106,7 +111,7 @@ export default class GrantBadgeModal extends Component {
|
||||
<p>
|
||||
<ComboBox
|
||||
@value={{this.selectedBadgeId}}
|
||||
@content={{this.availableBadges}}
|
||||
@content={{this.badgeOptions}}
|
||||
@onChange={{fn (mut this.selectedBadgeId)}}
|
||||
@options={{hash filterable=true none="badges.none"}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
createOne,
|
||||
deleteOne,
|
||||
readMany,
|
||||
readOne,
|
||||
updateOne,
|
||||
} from "discourse/data/builders/helpers";
|
||||
import {
|
||||
normalizeBadgeRecordPayload,
|
||||
normalizeBadgesPayload,
|
||||
} from "discourse/data/normalize";
|
||||
import { applyQueryParams } from "discourse/lib/url";
|
||||
|
||||
export function findBadges(opts = {}) {
|
||||
const url = applyQueryParams("/badges.json", {
|
||||
only_listable: opts.onlyListable ? "true" : null,
|
||||
});
|
||||
return readMany(url, normalizeBadgesPayload);
|
||||
}
|
||||
|
||||
export function findBadge(id) {
|
||||
return readOne(
|
||||
`/badges/${encodeURIComponent(id)}`,
|
||||
normalizeBadgeRecordPayload
|
||||
);
|
||||
}
|
||||
|
||||
export function saveBadge(badge, data) {
|
||||
if (badge.id != null) {
|
||||
return updateOne(
|
||||
"badge",
|
||||
badge.id,
|
||||
`/admin/badges/${encodeURIComponent(badge.id)}`,
|
||||
data,
|
||||
normalizeBadgeRecordPayload
|
||||
);
|
||||
}
|
||||
return createOne(`/admin/badges`, data, normalizeBadgeRecordPayload);
|
||||
}
|
||||
|
||||
export function deleteBadge(id) {
|
||||
return deleteOne("badge", id, `/admin/badges/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { deleteOne } from "discourse/data/builders/helpers";
|
||||
|
||||
export function deleteBookmark(id) {
|
||||
return deleteOne("bookmark", id, `/bookmarks/${encodeURIComponent(id)}.json`);
|
||||
}
|
||||
|
||||
export function togglePinBookmark(id) {
|
||||
return {
|
||||
url: `/bookmarks/${encodeURIComponent(id)}/toggle_pin`,
|
||||
method: "PUT",
|
||||
};
|
||||
}
|
||||
|
||||
// `operation` is a `{ type, ...args }` object passed through as the body.
|
||||
export function bulkBookmarkOperation(bookmarkIds, operation) {
|
||||
return {
|
||||
url: `/bookmarks/bulk`,
|
||||
method: "PUT",
|
||||
options: { body: { bookmark_ids: bookmarkIds, operation } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// CRUD shorthands for per-resource builders. RPC-style endpoints (toggles,
|
||||
// notification updates, remove-allowed-X) skip these and use plain inline
|
||||
// request objects — they don't fit the CRUD shape.
|
||||
|
||||
export function readMany(url, normalize) {
|
||||
return { url, method: "GET", op: "query", options: { normalize } };
|
||||
}
|
||||
|
||||
export function readOne(url, normalize) {
|
||||
return { url, method: "GET", op: "findRecord", options: { normalize } };
|
||||
}
|
||||
|
||||
export function createOne(url, body, normalize) {
|
||||
return {
|
||||
url,
|
||||
method: "POST",
|
||||
op: "createRecord",
|
||||
options: { body, normalize },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateOne(type, id, url, body, normalize) {
|
||||
return {
|
||||
url,
|
||||
method: "PUT",
|
||||
op: "updateRecord",
|
||||
data: { type, id: String(id) },
|
||||
options: { body, normalize },
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteOne(type, id, url) {
|
||||
return {
|
||||
url,
|
||||
method: "DELETE",
|
||||
op: "deleteRecord",
|
||||
data: { type, id: String(id) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// RPC-style endpoints; not CRUD, so they bypass `helpers.js`.
|
||||
|
||||
export function updateTopicNotificationLevel(topicId, level) {
|
||||
return {
|
||||
url: `/t/${encodeURIComponent(topicId)}/notifications`,
|
||||
method: "POST",
|
||||
options: { body: { notification_level: level } },
|
||||
};
|
||||
}
|
||||
|
||||
export function removeAllowedTopicGroup(topicId, name) {
|
||||
return {
|
||||
url: `/t/${encodeURIComponent(topicId)}/remove-allowed-group`,
|
||||
method: "PUT",
|
||||
options: { body: { name } },
|
||||
};
|
||||
}
|
||||
|
||||
export function removeAllowedTopicUser(topicId, username) {
|
||||
return {
|
||||
url: `/t/${encodeURIComponent(topicId)}/remove-allowed-user`,
|
||||
method: "PUT",
|
||||
options: { body: { username } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
createOne,
|
||||
deleteOne,
|
||||
readMany,
|
||||
} from "discourse/data/builders/helpers";
|
||||
import {
|
||||
normalizeUserBadgeRecordPayload,
|
||||
normalizeUserBadgesPayload,
|
||||
} from "discourse/data/normalize";
|
||||
import { applyQueryParams } from "discourse/lib/url";
|
||||
|
||||
// `/user-badges/:username` (dashed) and `/user_badges` (underscored) are
|
||||
// distinct Rails routes — not a typo.
|
||||
|
||||
export function findUserBadgesByUsername(username, opts = {}) {
|
||||
const url = applyQueryParams(
|
||||
`/user-badges/${encodeURIComponent(username)}.json`,
|
||||
{ grouped: opts.grouped ? "true" : null }
|
||||
);
|
||||
return readMany(url, normalizeUserBadgesPayload);
|
||||
}
|
||||
|
||||
export function findUserBadgesByBadgeId(badgeId, opts = {}) {
|
||||
const url = applyQueryParams("/user_badges.json", {
|
||||
badge_id: badgeId,
|
||||
offset: opts.offset,
|
||||
username: opts.username,
|
||||
});
|
||||
return readMany(url, normalizeUserBadgesPayload);
|
||||
}
|
||||
|
||||
export function grantUserBadge(badgeId, username, reason) {
|
||||
return createOne(
|
||||
`/user_badges`,
|
||||
{ username, badge_id: badgeId, reason },
|
||||
normalizeUserBadgeRecordPayload
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteUserBadge(id) {
|
||||
return deleteOne("user-badge", id, `/user_badges/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// RPC-style: no `op` / `data` / normalizer.
|
||||
export function toggleFavoriteUserBadge(id) {
|
||||
return {
|
||||
url: `/user_badges/${encodeURIComponent(id)}/toggle_favorite`,
|
||||
method: "PUT",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { trackedObject } from "@ember/reactive/collections";
|
||||
|
||||
// Legacy `RestModel` kept whatever the server sent. A WarpDrive cache only
|
||||
// keeps what the schema declares, and core's schemas can't know about the
|
||||
// attributes plugins add (`add_to_serializer`) or the ad-hoc keys callers pass
|
||||
// to `create`. Those are retained here instead of being dropped, keyed by
|
||||
// identity so every wrapper around the same cached record reads the same
|
||||
// values.
|
||||
const registry = new Map();
|
||||
|
||||
function keyFor(type, id) {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
export function recordExtraAttributes(type, id, attributes) {
|
||||
if (Object.keys(attributes).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = keyFor(type, id);
|
||||
let stored = registry.get(key);
|
||||
if (!stored) {
|
||||
stored = trackedObject({});
|
||||
registry.set(key, stored);
|
||||
}
|
||||
|
||||
Object.assign(stored, attributes);
|
||||
}
|
||||
|
||||
export function extraAttributesFor(type, id) {
|
||||
return registry.get(keyFor(type, id));
|
||||
}
|
||||
|
||||
// Exposes `attributes` as own accessors on `target`. Names that already resolve
|
||||
// (a schema forwarder, a getter, a method) win and are left alone. Defining
|
||||
// accessors rather than copying values keeps later payloads visible: they land
|
||||
// in the same `attributes` object.
|
||||
export function exposeExtraAttributes(target, attributes) {
|
||||
for (const name of Object.keys(attributes)) {
|
||||
if (name in target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object.defineProperty(target, name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => attributes[name],
|
||||
set: (value) => {
|
||||
attributes[name] = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// USE ONLY FOR TESTING PURPOSES.
|
||||
export function clearExtraAttributes() {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
const discourseRestHandler = {
|
||||
async request(context, next) {
|
||||
const request = context.request;
|
||||
if (!request.url) {
|
||||
return next(request);
|
||||
}
|
||||
|
||||
const ajaxOptions = { type: (request.method ?? "GET").toUpperCase() };
|
||||
const body = request.options?.body;
|
||||
if (body !== undefined && body !== null) {
|
||||
ajaxOptions.data = body;
|
||||
}
|
||||
|
||||
const raw = await ajax(request.url, ajaxOptions);
|
||||
const normalize = request.options?.normalize;
|
||||
if (normalize) {
|
||||
return normalize(raw);
|
||||
}
|
||||
// No normalizer (delete, custom actions) — `{ data: null }` is the only
|
||||
// shape the cache validator accepts as a no-op.
|
||||
return { data: null };
|
||||
},
|
||||
};
|
||||
|
||||
export default discourseRestHandler;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { recordExtraAttributes } from "discourse/data/extra-attributes";
|
||||
|
||||
// Schemas are immutable module constants, so their field breakdown is computed
|
||||
// once rather than per normalized record.
|
||||
const schemaFields = new WeakMap();
|
||||
|
||||
function fieldsFor(schema) {
|
||||
let entry = schemaFields.get(schema);
|
||||
if (!entry) {
|
||||
const declared = new Set();
|
||||
const attributes = [];
|
||||
if (schema.identity?.name) {
|
||||
declared.add(schema.identity.name);
|
||||
}
|
||||
for (const field of schema.fields ?? []) {
|
||||
declared.add(field.name);
|
||||
if (field.kind === "attribute") {
|
||||
attributes.push(field.name);
|
||||
}
|
||||
}
|
||||
entry = { declared, attributes };
|
||||
schemaFields.set(schema, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Builds the JSON:API resource object for `raw`: schema-declared attributes go
|
||||
// on the resource, the remaining keys are retained as extra attributes (see
|
||||
// `extra-attributes.js`). `id` defaults to `raw.id` — pass it explicitly for
|
||||
// sub-resources whose identity comes from the parent.
|
||||
export function resourceFrom(type, schema, raw, id = raw.id) {
|
||||
id = String(id);
|
||||
const { declared, attributes } = fieldsFor(schema);
|
||||
|
||||
const out = {};
|
||||
for (const name of attributes) {
|
||||
if (name in raw) {
|
||||
out[name] = raw[name];
|
||||
}
|
||||
}
|
||||
|
||||
const extras = {};
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
if (!declared.has(name)) {
|
||||
extras[name] = value;
|
||||
}
|
||||
}
|
||||
recordExtraAttributes(type, id, extras);
|
||||
|
||||
return { type, id, attributes: out };
|
||||
}
|
||||
|
||||
export function indexIncluded(included) {
|
||||
return new Set(included.map((r) => `${r.type}:${r.id}`));
|
||||
}
|
||||
|
||||
// Add a relationship pointer only when the related resource is in
|
||||
// `includedIds` — referencing a missing identity logs a cache-validator
|
||||
// warning and leaves a dangling pointer on cold loads.
|
||||
export function maybeRelate(relationships, name, includedIds, type, id) {
|
||||
if (id == null || !includedIds.has(`${type}:${id}`)) {
|
||||
return;
|
||||
}
|
||||
relationships[name] = { data: { type, id: String(id) } };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
indexIncluded,
|
||||
maybeRelate,
|
||||
resourceFrom,
|
||||
} from "discourse/data/jsonapi-utils";
|
||||
import { BadgeSchema } from "discourse/data/schemas/badge";
|
||||
import { BadgeGroupingSchema } from "discourse/data/schemas/badge-grouping";
|
||||
import { BadgeTypeSchema } from "discourse/data/schemas/badge-type";
|
||||
import { TopicDetailsSchema } from "discourse/data/schemas/topic-details";
|
||||
import { UserBadgeSchema } from "discourse/data/schemas/user-badge";
|
||||
import { badgeGroupingDisplayName } from "discourse/models/badge-grouping";
|
||||
|
||||
function badgeResource(raw, includedIds) {
|
||||
const resource = resourceFrom("badge", BadgeSchema, raw);
|
||||
const relationships = {};
|
||||
maybeRelate(
|
||||
relationships,
|
||||
"badge_type",
|
||||
includedIds,
|
||||
"badge-type",
|
||||
raw.badge_type_id
|
||||
);
|
||||
maybeRelate(
|
||||
relationships,
|
||||
"badge_grouping",
|
||||
includedIds,
|
||||
"badge-grouping",
|
||||
raw.badge_grouping_id
|
||||
);
|
||||
resource.relationships = relationships;
|
||||
return resource;
|
||||
}
|
||||
|
||||
function badgeTypeResource(raw) {
|
||||
return resourceFrom("badge-type", BadgeTypeSchema, raw);
|
||||
}
|
||||
|
||||
function badgeGroupingResource(raw) {
|
||||
const resource = resourceFrom("badge-grouping", BadgeGroupingSchema, raw);
|
||||
if (raw.name) {
|
||||
resource.attributes.displayName = badgeGroupingDisplayName(raw.name);
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
function userBadgeResource(raw, lookup, includedIds) {
|
||||
const resource = resourceFrom("user-badge", UserBadgeSchema, raw);
|
||||
const { attributes } = resource;
|
||||
// Inline sideloads as plain objects so templates can read arbitrary fields
|
||||
// without hitting LegacyMode's strict schema check on cached records.
|
||||
if (raw.user_id != null) {
|
||||
attributes.user = lookup.user(raw.user_id);
|
||||
}
|
||||
if (raw.granted_by_id != null) {
|
||||
attributes.granted_by = lookup.user(raw.granted_by_id);
|
||||
}
|
||||
if (raw.topic_id != null) {
|
||||
attributes.topic = lookup.topic(raw.topic_id);
|
||||
}
|
||||
|
||||
const relationships = {};
|
||||
maybeRelate(relationships, "badge", includedIds, "badge", raw.badge_id);
|
||||
resource.relationships = relationships;
|
||||
return resource;
|
||||
}
|
||||
|
||||
function collectBadgeMetaIncluded(payload, included) {
|
||||
for (const raw of payload.badge_types ?? []) {
|
||||
included.push(badgeTypeResource(raw));
|
||||
}
|
||||
for (const raw of payload.badge_groupings ?? []) {
|
||||
included.push(badgeGroupingResource(raw));
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts:
|
||||
// { badge: {...}, badge_types: [...], badge_groupings?: [...] } (show)
|
||||
// { badges: [...], badge_types: [...], badge_groupings: [...] } (index)
|
||||
// A payload carrying neither is an empty collection, not an absent record:
|
||||
// badges ride along in larger payloads (a user summary, a post) that may
|
||||
// simply have none, and callers there expect a list. Only a missing payload
|
||||
// is `{ data: null }` (no `included` — JSON:API forbids it when data is null).
|
||||
export function normalizeBadgesPayload(payload) {
|
||||
if (!payload) {
|
||||
return { data: null };
|
||||
}
|
||||
const included = [];
|
||||
collectBadgeMetaIncluded(payload, included);
|
||||
const includedIds = indexIncluded(included);
|
||||
|
||||
if (payload.badge) {
|
||||
return { data: badgeResource(payload.badge, includedIds), included };
|
||||
}
|
||||
return {
|
||||
data: (payload.badges ?? []).map((raw) => badgeResource(raw, includedIds)),
|
||||
included,
|
||||
};
|
||||
}
|
||||
|
||||
// Accepts:
|
||||
// { user_badge: {...}, badges, badge_types, users, topics, granted_bies } (grant POST)
|
||||
// { user_badges: [...], badges, badge_types, users, topics, granted_bies } (findByUsername)
|
||||
// { user_badge_info: { user_badges, grant_count, username }, badges, ... } (findByBadgeId)
|
||||
export function normalizeUserBadgesPayload(payload) {
|
||||
if (!payload) {
|
||||
return { data: null };
|
||||
}
|
||||
const included = [];
|
||||
collectBadgeMetaIncluded(payload, included);
|
||||
// Index BEFORE pushing sideloaded badges so each badge's relationships only
|
||||
// reference badge-type / badge-grouping resources we actually included.
|
||||
const badgeRelIds = indexIncluded(included);
|
||||
for (const raw of payload.badges ?? []) {
|
||||
included.push(badgeResource(raw, badgeRelIds));
|
||||
}
|
||||
const includedIds = indexIncluded(included);
|
||||
|
||||
const usersById = new Map();
|
||||
for (const raw of [
|
||||
...(payload.users ?? []),
|
||||
...(payload.granted_bies ?? []),
|
||||
]) {
|
||||
if (!usersById.has(raw.id)) {
|
||||
usersById.set(raw.id, raw);
|
||||
}
|
||||
}
|
||||
const topicsById = new Map();
|
||||
for (const raw of payload.topics ?? []) {
|
||||
topicsById.set(raw.id, raw);
|
||||
}
|
||||
const lookup = {
|
||||
user: (id) => usersById.get(id),
|
||||
topic: (id) => topicsById.get(id),
|
||||
};
|
||||
|
||||
if (payload.user_badge) {
|
||||
return {
|
||||
data: userBadgeResource(payload.user_badge, lookup, includedIds),
|
||||
included,
|
||||
};
|
||||
}
|
||||
|
||||
const wrapper = payload.user_badge_info;
|
||||
const rawUserBadges = wrapper?.user_badges ?? payload.user_badges ?? [];
|
||||
const doc = {
|
||||
data: rawUserBadges.map((ub) => userBadgeResource(ub, lookup, includedIds)),
|
||||
included,
|
||||
};
|
||||
if (wrapper) {
|
||||
doc.meta = {
|
||||
grant_count: wrapper.grant_count,
|
||||
username: wrapper.username,
|
||||
};
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Single-record ops (findRecord / createRecord / updateRecord) must resolve to
|
||||
// exactly one resource, so a payload without the record key is a no-op rather
|
||||
// than an empty collection — `{ data: [] }` fails the cache validator.
|
||||
function recordOnly(normalize, rootKey) {
|
||||
return (payload) =>
|
||||
payload?.[rootKey] ? normalize(payload) : { data: null };
|
||||
}
|
||||
|
||||
export const normalizeBadgeRecordPayload = recordOnly(
|
||||
normalizeBadgesPayload,
|
||||
"badge"
|
||||
);
|
||||
|
||||
export const normalizeUserBadgeRecordPayload = recordOnly(
|
||||
normalizeUserBadgesPayload,
|
||||
"user_badge"
|
||||
);
|
||||
|
||||
export function normalizeTopicDetailsPayload({ topicId, details }) {
|
||||
return {
|
||||
data: resourceFrom("topic-details", TopicDetailsSchema, details, topicId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import {
|
||||
get as emberGet,
|
||||
getProperties as emberGetProperties,
|
||||
} from "@ember/object";
|
||||
import { trackedObject } from "@ember/reactive/collections";
|
||||
import { exposeExtraAttributes } from "discourse/data/extra-attributes";
|
||||
import WarpRestModel from "discourse/data/warp-rest-model";
|
||||
import {
|
||||
applyModelCallbacks,
|
||||
applyRegisteredFields,
|
||||
extraSavePropertiesFor,
|
||||
modelNameFor,
|
||||
} from "discourse/lib/model-extensions";
|
||||
|
||||
// Attrs bags handed to `create`, as opposed to cached records. Tracked by
|
||||
// identity because the two are indistinguishable by shape, and reading an
|
||||
// undeclared field off a cached record throws rather than returning undefined.
|
||||
const draftResources = new WeakSet();
|
||||
|
||||
// Bridges Discourse's legacy `Store` + `RestModel` callsites to WarpRestModel.
|
||||
// Drop this layer (extend WarpRestModel directly) once a model's callers no
|
||||
// longer use `.get` / `.set` / `.setProperties` / `store.createRecord` /
|
||||
// `record.save` / `record.destroyRecord`.
|
||||
export default class RestCompatModel extends WarpRestModel {
|
||||
// Identity by default; legacy `service:store._hydrate` calls this on cache
|
||||
// updates. Subclasses can override to massage JSON before it lands.
|
||||
static munge(json) {
|
||||
return json;
|
||||
}
|
||||
|
||||
// Draft attrs go into a `trackedObject` so field reads/writes are reactive
|
||||
// — Glimmer templates rerender when callers do `bookmark.set("name", ...)`,
|
||||
// matching the old EmberObject behavior. Cached LegacyMode records have
|
||||
// their own signal-based reactivity.
|
||||
static create(attrs = {}) {
|
||||
// Re-wrapping an existing instance was a harmless copy under EmberObject;
|
||||
// the wrapper's attrs now live in a private resource, so a spread would
|
||||
// drop them all. Hand the instance back instead.
|
||||
if (attrs instanceof this) {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
const resource = trackedObject({ ...attrs });
|
||||
draftResources.add(resource);
|
||||
const wrapper = new this(resource);
|
||||
wrapper.store = attrs.store;
|
||||
wrapper.__type = attrs.__type;
|
||||
wrapper.__state = attrs.__state;
|
||||
// Legacy `create(json)` took arbitrary keys; only schema fields have
|
||||
// prototype forwarders, so expose the rest straight off the draft.
|
||||
exposeExtraAttributes(wrapper, resource);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@tracked isSaving = false;
|
||||
|
||||
// Subclasses override (e.g. `TagNotification` uses `"name"`).
|
||||
primaryKey = "id";
|
||||
|
||||
// `Store._build` stamps these onto the raw attrs. They are legacy bookkeeping
|
||||
// rather than schema fields, so — as in `RestModel` — they belong to the
|
||||
// wrapper. Reading them back through `__resource` works only while it is the
|
||||
// draft attrs bag: once the wrapper adopts a cached record, LegacyMode throws
|
||||
// on every field the schema doesn't declare, and `save()` reads `isNew`.
|
||||
store;
|
||||
__type;
|
||||
@tracked __state;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Defines plugin-registered fields (see `addModelField`) as tracked
|
||||
// properties on the wrapper. Plugin fields are outside the schema, so they
|
||||
// live here rather than in the cache, and a caller/server-provided value
|
||||
// wins over the registered default.
|
||||
//
|
||||
// Only a draft's attrs bag can be probed directly — it holds whatever the
|
||||
// caller passed. A cached record throws on any field its schema doesn't
|
||||
// declare, which is every plugin field, so go through the wrapper, where
|
||||
// the base constructor has already exposed the retained extras. Existence
|
||||
// is probed by reading rather than `in`: the `has` trap on a `trackedObject`
|
||||
// draft is unreliable, and an explicit `undefined` means omission here.
|
||||
//
|
||||
// Reads `__ownResource`, not `__resource`: subclasses override the latter to
|
||||
// resolve the resource from their own fields, which aren't initialized yet
|
||||
// during `super()`.
|
||||
applyRegisteredFields(this, (name) => {
|
||||
const resource = this.__ownResource;
|
||||
const value = draftResources.has(resource)
|
||||
? resource?.[name]
|
||||
: this[name];
|
||||
if (value !== undefined) {
|
||||
return { value };
|
||||
}
|
||||
});
|
||||
|
||||
// Fires `init` callbacks (see `addModelCallback`) with the create args
|
||||
// already in place, matching `RestModel`.
|
||||
applyModelCallbacks(modelNameFor(this), "init", this);
|
||||
}
|
||||
|
||||
// True until `save()` / `updateFromJson()` swaps in the cached record —
|
||||
// `_adoptResource` replaces the draft attrs bag, which is never in the set.
|
||||
get __isLocalDraft() {
|
||||
return draftResources.has(this.__ownResource);
|
||||
}
|
||||
|
||||
// Both "topicDetails" and "topic-details" resolve, and the plugin API keys
|
||||
// its registrations by whichever spelling was used. `constructor.type` would
|
||||
// apply a plugin's fields but silently drop its callbacks and save properties.
|
||||
get #modelName() {
|
||||
return modelNameFor(this);
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return this.__state === "new";
|
||||
}
|
||||
|
||||
get isCreated() {
|
||||
return this.__state === "created";
|
||||
}
|
||||
|
||||
get(path) {
|
||||
if (typeof path !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return emberGet(this, path);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
// Drafts: mutate the attrs bag directly. Cached records: route through
|
||||
// the prototype setter, which writes to the record's own field.
|
||||
if (this.__isLocalDraft) {
|
||||
this.__resource[key] = value;
|
||||
return value;
|
||||
}
|
||||
this[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
getProperties(...keys) {
|
||||
return emberGetProperties(this, ...keys);
|
||||
}
|
||||
|
||||
setProperties(hash) {
|
||||
if (!hash) {
|
||||
return hash;
|
||||
}
|
||||
for (const [key, value] of Object.entries(hash)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Subclass hooks from `RestModel`. Fired by the legacy save path only — new
|
||||
// code on the WarpDrive path uses `addModelCallback`.
|
||||
beforeCreate() {}
|
||||
afterCreate() {}
|
||||
|
||||
beforeUpdate() {}
|
||||
afterUpdate() {}
|
||||
|
||||
// Legacy `RestModel#save`. Goes through the WarpDrive path when the subclass
|
||||
// defines `static builders.save`; the legacy adapter pipeline otherwise.
|
||||
async save(data) {
|
||||
if (!this.constructor.builders?.save) {
|
||||
return this.isNew ? this._saveNew(data) : this.update(data);
|
||||
}
|
||||
|
||||
const props = this.#withSaveProperties(data);
|
||||
return this.#withCallbacks(this.isNew ? "Create" : "Update", props, () =>
|
||||
super.save(props)
|
||||
);
|
||||
}
|
||||
|
||||
// Runs `fn` between the registered `addModelCallback` before/after callbacks
|
||||
// for `kind` ("Create" | "Update" | "Destroy").
|
||||
async #withCallbacks(kind, props, fn) {
|
||||
const modelName = this.#modelName;
|
||||
await applyModelCallbacks(modelName, `before${kind}`, this, props);
|
||||
const res = await fn();
|
||||
await applyModelCallbacks(modelName, `after${kind}`, this, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// Merges plugin-registered save properties (see `addModelSaveProperty`) into
|
||||
// the outgoing payload.
|
||||
#withSaveProperties(data) {
|
||||
const extras = extraSavePropertiesFor(this.#modelName, this);
|
||||
return Object.keys(extras).length ? { ...data, ...extras } : data;
|
||||
}
|
||||
|
||||
async _saveNew(props) {
|
||||
props = this.#withSaveProperties(props);
|
||||
return this.#withSaving(() => {
|
||||
this.beforeCreate(props);
|
||||
return this.#withCallbacks("Create", props, async () => {
|
||||
const adapter = this.store.adapterFor(this.__type);
|
||||
const res = await adapter.createRecord(this.store, this.__type, props);
|
||||
if (res.payload) {
|
||||
this.setProperties(this.constructor.munge(res.payload));
|
||||
this.__state = "created";
|
||||
}
|
||||
res.target = this;
|
||||
this.afterCreate(res);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async update(props) {
|
||||
props = this.#withSaveProperties(props);
|
||||
return this.#withSaving(() => {
|
||||
this.beforeUpdate(props);
|
||||
return this.#withCallbacks("Update", props, async () => {
|
||||
const res = await this.store.update(
|
||||
this.__type,
|
||||
this[this.primaryKey],
|
||||
props
|
||||
);
|
||||
const payload = this.constructor.munge(res.payload || res.responseJson);
|
||||
if (payload && payload.success !== "OK") {
|
||||
this.setProperties(payload);
|
||||
}
|
||||
res.target = this;
|
||||
this.afterUpdate(res);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroyRecord() {
|
||||
return this.#withCallbacks("Destroy", undefined, () =>
|
||||
this.store.destroyRecord(this.__type, this)
|
||||
);
|
||||
}
|
||||
|
||||
// `WarpRestModel#destroy` (builder-driven delete), wrapped with callbacks.
|
||||
async destroy() {
|
||||
await this.#withCallbacks("Destroy", undefined, () => super.destroy());
|
||||
}
|
||||
|
||||
async #withSaving(fn) {
|
||||
if (this.isSaving) {
|
||||
return Promise.reject(new Error("model is already saving"));
|
||||
}
|
||||
this.isSaving = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// `site` is stamped on at runtime by `Site.create` so `isDefault` can compare
|
||||
// against `this.site.default_archetype`.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const ArchetypeSchema = withDefaults({
|
||||
type: "archetype",
|
||||
fields: [...attrs("name", "options", "site")],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const BadgeGroupingSchema = withDefaults({
|
||||
type: "badge-grouping",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"description",
|
||||
"position",
|
||||
"system",
|
||||
// Server doesn't ship `displayName` — populated at normalize time via i18n.
|
||||
"displayName"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const BadgeTypeSchema = withDefaults({
|
||||
type: "badge-type",
|
||||
fields: [...attrs("name", "sort_order")],
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { attrs, belongsTo, withDefaults } from "./helpers";
|
||||
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const BadgeSchema = withDefaults({
|
||||
type: "badge",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"description",
|
||||
"long_description",
|
||||
"slug",
|
||||
"icon",
|
||||
"image_url",
|
||||
"grant_count",
|
||||
"enabled",
|
||||
"listable",
|
||||
"show_in_post_header",
|
||||
"has_badge",
|
||||
"allow_title",
|
||||
"multiple_grant",
|
||||
"manually_grantable",
|
||||
"system",
|
||||
// FKs alongside the relations so callers can read them directly.
|
||||
"badge_type_id",
|
||||
"badge_grouping_id",
|
||||
// AdminBadgeSerializer-only — declared so the cache preserves them.
|
||||
"query",
|
||||
"trigger",
|
||||
"target_posts",
|
||||
"auto_revoke",
|
||||
"show_posts",
|
||||
"i18n_name",
|
||||
"image_upload_id"
|
||||
),
|
||||
belongsTo("badge_type", "badge-type"),
|
||||
belongsTo("badge_grouping", "badge-grouping"),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// `user` is opaque — wrapped as a `User` instance in `Bookmark.create` rather
|
||||
// than treated as a relationship (User isn't migrated yet).
|
||||
//
|
||||
// `currentUser` and `topicStatus` are not schema fields — they're set as own
|
||||
// properties on the wrapper by `Bookmark.create` and the user-activity
|
||||
// controller's `transform`, respectively.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const BookmarkSchema = withDefaults({
|
||||
type: "bookmark",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reminder_at",
|
||||
"reminder_at_ics_start",
|
||||
"reminder_at_ics_end",
|
||||
"pinned",
|
||||
"title",
|
||||
"fancy_title",
|
||||
"fancy_title_localized",
|
||||
"locale",
|
||||
"excerpt",
|
||||
"bookmarkable_id",
|
||||
"bookmarkable_type",
|
||||
"bookmarkable_url",
|
||||
"topic_id",
|
||||
"linked_post_number",
|
||||
"deleted",
|
||||
"hidden",
|
||||
"category_id",
|
||||
"closed",
|
||||
"archived",
|
||||
"archetype",
|
||||
"highest_post_number",
|
||||
"bumped_at",
|
||||
"slug",
|
||||
"tags",
|
||||
"tags_descriptions",
|
||||
"truncated",
|
||||
"post_id",
|
||||
// Only on bookmarks embedded in topic-view payloads (TopicViewBookmarkSerializer).
|
||||
"post_number",
|
||||
"last_read_post_number",
|
||||
"is_warning",
|
||||
"invisible",
|
||||
"user",
|
||||
// Local-only — set by `Bookmark.createFor` and `BookmarkFormData.saveData`.
|
||||
"auto_delete_preference",
|
||||
"user_id"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Schemas annotate their export with `@type {LegacyResourceSchema}` so the
|
||||
// emitted `.d.ts` doesn't anchor through pnpm's `.pnpm/...` path (TS2883).
|
||||
export { withDefaults } from "@warp-drive/legacy/model/migration-support";
|
||||
|
||||
export function attrs(...names) {
|
||||
return names.map((name) => ({ kind: "attribute", name }));
|
||||
}
|
||||
|
||||
export function belongsTo(name, type) {
|
||||
return {
|
||||
kind: "belongsTo",
|
||||
name,
|
||||
type,
|
||||
options: { async: false, inverse: null },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ArchetypeSchema } from "./archetype";
|
||||
import { BadgeSchema } from "./badge";
|
||||
import { BadgeGroupingSchema } from "./badge-grouping";
|
||||
import { BadgeTypeSchema } from "./badge-type";
|
||||
import { BookmarkSchema } from "./bookmark";
|
||||
import { TagSchema } from "./tag";
|
||||
import { TagGroupSchema } from "./tag-group";
|
||||
import { TagInfoSchema } from "./tag-info";
|
||||
import { TagNotificationSchema } from "./tag-notification";
|
||||
import { TagSettingsSchema } from "./tag-settings";
|
||||
import { TopicDetailsSchema } from "./topic-details";
|
||||
import { UserBadgeSchema } from "./user-badge";
|
||||
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema[]} */
|
||||
export const schemas = [
|
||||
ArchetypeSchema,
|
||||
BadgeSchema,
|
||||
BadgeTypeSchema,
|
||||
BadgeGroupingSchema,
|
||||
BookmarkSchema,
|
||||
TagSchema,
|
||||
TagGroupSchema,
|
||||
TagInfoSchema,
|
||||
TagNotificationSchema,
|
||||
TagSettingsSchema,
|
||||
TopicDetailsSchema,
|
||||
UserBadgeSchema,
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// TagGroup stays on the legacy `Store` + `RestAdapter` path (admin CRUD via
|
||||
// `record.save()` / `record.destroyRecord()`). Schema only powers the
|
||||
// wrapper's field forwarders. `tags` / `parent_tag` / `permissions` are
|
||||
// opaque payloads.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TagGroupSchema = withDefaults({
|
||||
type: "tag-group",
|
||||
fields: [
|
||||
...attrs("name", "tags", "parent_tag", "one_per_topic", "permissions"),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// Loaded via `/tag/:id/info.json` (DetailedTagSerializer). Schema covers the
|
||||
// fields the legacy model exposed via `@tracked` / `@autoTrackedArray`.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TagInfoSchema = withDefaults({
|
||||
type: "tag-info",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"topic_count",
|
||||
"staff",
|
||||
"category_restricted",
|
||||
"categories",
|
||||
"localizations",
|
||||
"synonyms",
|
||||
"tag_group_names"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// Identity key for the legacy `Store.update` URL routing is `name`, not `id`
|
||||
// — the model sets `primaryKey = "name"` so `/tag/${name}/notifications.json`
|
||||
// resolves correctly when the user changes the notification level.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TagNotificationSchema = withDefaults({
|
||||
type: "tag-notification",
|
||||
fields: [...attrs("name", "notification_level")],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TagSettingsSchema = withDefaults({
|
||||
type: "tag-settings",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"synonyms",
|
||||
"tag_group_names",
|
||||
"tag_groups",
|
||||
"category_restricted",
|
||||
"can_edit",
|
||||
"can_admin",
|
||||
"categories",
|
||||
"localizations"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// Tag stays on the legacy `Store` + `RestAdapter` path; this schema only
|
||||
// powers the wrapper's field forwarders. `target_tag` / `localizations` are
|
||||
// opaque (their target models aren't migrated).
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TagSchema = withDefaults({
|
||||
type: "tag",
|
||||
fields: [
|
||||
...attrs(
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"text",
|
||||
"count",
|
||||
"pm_count",
|
||||
"pm_only",
|
||||
"topic_count",
|
||||
"staff",
|
||||
"target_tag",
|
||||
"localizations"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { attrs, withDefaults } from "./helpers";
|
||||
|
||||
// Sub-resource owned by a Topic; identity = parent topic's id. Ships embedded
|
||||
// in the topic-view payload, not fetched standalone.
|
||||
//
|
||||
// `created_by`, `last_poster`, `participants`, `links`, `allowed_users`,
|
||||
// `allowed_groups` are opaque attributes — their target models aren't migrated.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const TopicDetailsSchema = withDefaults({
|
||||
type: "topic-details",
|
||||
fields: [
|
||||
...attrs(
|
||||
"can_edit",
|
||||
"can_move_posts",
|
||||
"can_delete",
|
||||
"can_permanently_delete",
|
||||
"can_recover",
|
||||
"can_remove_allowed_users",
|
||||
"can_invite_to",
|
||||
"can_invite_via_email",
|
||||
"can_create_post",
|
||||
"can_reply_as_new_topic",
|
||||
"can_flag_topic",
|
||||
"can_convert_topic",
|
||||
"can_review_topic",
|
||||
"can_edit_tags",
|
||||
"can_publish_page",
|
||||
"can_close_topic",
|
||||
"can_archive_topic",
|
||||
"can_split_merge_topic",
|
||||
"can_edit_staff_notes",
|
||||
"can_toggle_topic_visibility",
|
||||
"can_pin_unpin_topic",
|
||||
"can_banner_topic",
|
||||
"can_moderate_category",
|
||||
"can_remove_self_id",
|
||||
"notification_level",
|
||||
"notifications_reason_id",
|
||||
"created_by",
|
||||
"last_poster",
|
||||
"participants",
|
||||
"links",
|
||||
"allowed_users",
|
||||
"allowed_groups"
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { attrs, belongsTo, withDefaults } from "./helpers";
|
||||
|
||||
// `user` / `granted_by` / `topic` are opaque attributes rather than relations
|
||||
// — those models aren't migrated yet; LegacyMode would throw on every unknown
|
||||
// field read against a cached `user` / `topic` record.
|
||||
/** @type {import("@warp-drive/core/types/schema/fields").LegacyResourceSchema} */
|
||||
export const UserBadgeSchema = withDefaults({
|
||||
type: "user-badge",
|
||||
fields: [
|
||||
...attrs(
|
||||
"granted_at",
|
||||
"created_at",
|
||||
"count",
|
||||
"post_id",
|
||||
"post_number",
|
||||
"grouping_position",
|
||||
"topic_id",
|
||||
"topic_title",
|
||||
"is_favorite",
|
||||
"can_favorite",
|
||||
"user",
|
||||
"granted_by",
|
||||
"topic"
|
||||
),
|
||||
belongsTo("badge", "badge"),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
exposeExtraAttributes,
|
||||
extraAttributesFor,
|
||||
} from "discourse/data/extra-attributes";
|
||||
import { getOwnerWithFallback } from "discourse/lib/get-owner";
|
||||
|
||||
export function warpStore() {
|
||||
return getOwnerWithFallback().lookup("service:warp-store");
|
||||
}
|
||||
|
||||
// Pure WarpDrive base. Ember/RestModel-API shims live in `rest-compat.js`.
|
||||
//
|
||||
// Subclass contract:
|
||||
// static type — schema type, e.g. "badge"
|
||||
// static normalize — rooted JSON → JSON:API document
|
||||
// static builders — { list(opts), one(id), save(record, data), delete(id) }
|
||||
export default class WarpRestModel {
|
||||
static type = null;
|
||||
static normalize = null;
|
||||
static builders = null;
|
||||
|
||||
static findAll(opts) {
|
||||
return requestMany(this, this.builders.list(opts));
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return requestOne(this, this.builders.one(id));
|
||||
}
|
||||
|
||||
// Synchronous ingest for preloaded payloads (PreloadStore, embedded sub-payloads).
|
||||
static createFromJson(json) {
|
||||
const store = warpStore();
|
||||
const document = this.normalize(json);
|
||||
|
||||
if (Array.isArray(document.data)) {
|
||||
const records = store.push(document);
|
||||
return attachMeta(
|
||||
records.map((r) => new this(r)),
|
||||
document.meta
|
||||
);
|
||||
}
|
||||
return new this(store.push(document));
|
||||
}
|
||||
|
||||
#resource;
|
||||
|
||||
constructor(resource) {
|
||||
this.#resource = resource;
|
||||
this._applyExtraAttributes(resource?.id);
|
||||
}
|
||||
|
||||
get __resource() {
|
||||
return this.#resource;
|
||||
}
|
||||
|
||||
// The resource this wrapper was constructed with, bypassing any subclass
|
||||
// `__resource` override. Base constructors must use this: a subclass's own
|
||||
// fields (and the getter that reads them) aren't initialized during `super()`.
|
||||
get __ownResource() {
|
||||
return this.#resource;
|
||||
}
|
||||
|
||||
// Attributes the schema doesn't declare never reach the cache; re-attach the
|
||||
// ones normalization retained for this identity. Called on construction and
|
||||
// whenever the wrapper adopts a cached record — subclasses with their own
|
||||
// ingest path (`TopicDetails`) call it after pushing.
|
||||
_applyExtraAttributes(id) {
|
||||
const { type } = this.constructor;
|
||||
if (type == null || id == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extras = extraAttributesFor(type, String(id));
|
||||
if (extras) {
|
||||
exposeExtraAttributes(this, extras);
|
||||
}
|
||||
}
|
||||
|
||||
// Swap `__resource` to the cached record for `id`.
|
||||
_adoptResource(id) {
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
const Klass = this.constructor;
|
||||
const cached = warpStore().peekRecord({
|
||||
type: Klass.type,
|
||||
id: String(id),
|
||||
});
|
||||
if (cached && cached !== this.#resource) {
|
||||
this.#resource = cached;
|
||||
}
|
||||
this._applyExtraAttributes(id);
|
||||
}
|
||||
|
||||
updateFromJson(json) {
|
||||
if (json == null) {
|
||||
return this;
|
||||
}
|
||||
const Klass = this.constructor;
|
||||
const document = Klass.normalize(json);
|
||||
if (!document || Array.isArray(document.data)) {
|
||||
return this;
|
||||
}
|
||||
warpStore().push(document);
|
||||
this._adoptResource(document.data?.id);
|
||||
return this;
|
||||
}
|
||||
|
||||
async save(data) {
|
||||
const Klass = this.constructor;
|
||||
const store = warpStore();
|
||||
const result = await store.request(Klass.builders.save(this, data));
|
||||
this._adoptResource(result.content?.data?.id);
|
||||
return this;
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
const Klass = this.constructor;
|
||||
const id = this.id;
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
await warpStore().request(Klass.builders.delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
// Attach document-level `meta` keys to the returned array so legacy callers
|
||||
// can read `result.grant_count` / `result.username` directly.
|
||||
export function attachMeta(records, meta) {
|
||||
if (meta) {
|
||||
Object.assign(records, meta);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
export async function requestMany(Klass, request) {
|
||||
const { content } = await warpStore().request(request);
|
||||
return attachMeta(
|
||||
(content?.data ?? []).map((r) => new Klass(r)),
|
||||
content?.meta
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestOne(Klass, request) {
|
||||
const { content } = await warpStore().request(request);
|
||||
return new Klass(content?.data);
|
||||
}
|
||||
|
||||
// Install prototype getters/setters reading/writing `__resource` for each
|
||||
// schema field. Subclass-defined getters (image, url, ...) take precedence —
|
||||
// they're already on the prototype when this runs.
|
||||
export function defineFieldForwarders(Klass, schema) {
|
||||
const proto = Klass.prototype;
|
||||
const fieldKinds = new Map();
|
||||
if (schema.identity?.name) {
|
||||
fieldKinds.set(schema.identity.name, "@id");
|
||||
}
|
||||
for (const field of schema.fields ?? []) {
|
||||
if (field.name && !fieldKinds.has(field.name)) {
|
||||
fieldKinds.set(field.name, field.kind);
|
||||
}
|
||||
}
|
||||
for (const [name, kind] of fieldKinds) {
|
||||
// Skip names that resolve anywhere on the prototype chain — this is what
|
||||
// keeps subclass getters and base methods (save, get, set, ...) from
|
||||
// being shadowed by legacy-derived schema fields of the same name.
|
||||
if (name in proto) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.__resource?.[name];
|
||||
},
|
||||
};
|
||||
// JSON:API stores ids as strings; coerce numeric ones back so
|
||||
// `badge.id === 1126` still works at Discourse call sites.
|
||||
if (kind === "@id") {
|
||||
descriptor.get = function () {
|
||||
const raw = this.__resource?.[name];
|
||||
if (typeof raw !== "string") {
|
||||
return raw;
|
||||
}
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) && String(num) === raw ? num : raw;
|
||||
};
|
||||
}
|
||||
// Relationships are read-only through the wrapper.
|
||||
if (kind === "attribute") {
|
||||
descriptor.set = function (value) {
|
||||
if (this.__resource) {
|
||||
this.__resource[name] = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(proto, name, descriptor);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,29 @@
|
||||
import { convertIconClass } from "discourse/lib/icon-library";
|
||||
|
||||
export function grantableBadges(allBadges, userBadges) {
|
||||
const granted = userBadges.reduce((map, badge) => {
|
||||
map[badge.get("badge_id")] = true;
|
||||
return map;
|
||||
}, {});
|
||||
const granted = new Set(userBadges.map((ub) => ub.badge_id));
|
||||
|
||||
return allBadges
|
||||
.filter((badge) => {
|
||||
return (
|
||||
badge.get("enabled") &&
|
||||
badge.get("manually_grantable") &&
|
||||
(!granted[badge.get("id")] || badge.get("multiple_grant"))
|
||||
);
|
||||
})
|
||||
.map((badge) => {
|
||||
if (badge.get("icon")) {
|
||||
badge.set("icon", convertIconClass(badge.icon));
|
||||
}
|
||||
return badge;
|
||||
})
|
||||
.sort((a, b) => a.get("name").localeCompare(b.get("name")));
|
||||
.filter(
|
||||
(badge) =>
|
||||
badge.enabled &&
|
||||
badge.manually_grantable &&
|
||||
(!granted.has(badge.id) || badge.multiple_grant)
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// `ComboBox` renders `item.icon` directly, so it needs the converted class.
|
||||
// Derived rather than assigned back: badge records are shared through the
|
||||
// store, and everything else converts at render time (`d-icon-or-image`).
|
||||
export function grantableBadgeOptions(badges) {
|
||||
return badges.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
icon: badge.icon ? convertIconClass(badge.icon) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function isBadgeGrantable(badgeId, availableBadges) {
|
||||
return !!(
|
||||
availableBadges && availableBadges.some((b) => b.get("id") === badgeId)
|
||||
);
|
||||
return !!(availableBadges && availableBadges.some((b) => b.id === badgeId));
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ export function stampModelClass(klass, modelName) {
|
||||
}
|
||||
|
||||
export function modelNameFor(instance) {
|
||||
return instance?.constructor?.[MODEL_NAME];
|
||||
// WarpDrive models carry their resolver name as `static type`; fall back to
|
||||
// it so they resolve without depending on `stampModelClass` having run.
|
||||
return instance?.constructor?.[MODEL_NAME] ?? instance?.constructor?.type;
|
||||
}
|
||||
|
||||
// --- Fields (tracked data properties) ---
|
||||
@@ -100,31 +102,42 @@ function defineTrackedArrayField(instance, name, value) {
|
||||
);
|
||||
}
|
||||
|
||||
// Defines each registered field as a tracked property on the instance. Runs in
|
||||
// `RestModel`'s constructor, before the server payload, so a server value wins.
|
||||
export function applyRegisteredFields(instance) {
|
||||
function seedField(instance, name, value, type) {
|
||||
if (type === "array") {
|
||||
defineTrackedArrayField(instance, name, value);
|
||||
} else if (type === "object") {
|
||||
defineTrackedField(instance, name, trackedObject(value));
|
||||
} else if (type === "set") {
|
||||
defineTrackedField(instance, name, trackedSet(value));
|
||||
} else {
|
||||
defineTrackedField(instance, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Defines each registered field as a tracked property on the instance.
|
||||
//
|
||||
// `RestModel` calls this in its constructor, before the server payload is
|
||||
// assigned, so a server value wins by overwriting the seeded default later.
|
||||
// WarpDrive models have their attributes in place at construction, so they pass
|
||||
// `resolveInitial(name) -> { value }` to make a caller/server value win up front
|
||||
// (returning a falsy result falls back to the registered default).
|
||||
export function applyRegisteredFields(instance, resolveInitial) {
|
||||
const modelFields = fields.get(modelNameFor(instance));
|
||||
if (!modelFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, { defaultValue, type }] of modelFields) {
|
||||
const provided = resolveInitial?.(name);
|
||||
// Function defaults run per instance; a plain value is shared across
|
||||
// instances, so mutable defaults should use a function or a `type`.
|
||||
const value =
|
||||
typeof defaultValue === "function"
|
||||
const value = provided
|
||||
? provided.value
|
||||
: typeof defaultValue === "function"
|
||||
? defaultValue.call(instance)
|
||||
: defaultValue;
|
||||
|
||||
if (type === "array") {
|
||||
defineTrackedArrayField(instance, name, value);
|
||||
} else if (type === "object") {
|
||||
defineTrackedField(instance, name, trackedObject(value));
|
||||
} else if (type === "set") {
|
||||
defineTrackedField(instance, name, trackedSet(value));
|
||||
} else {
|
||||
defineTrackedField(instance, name, value);
|
||||
}
|
||||
seedField(instance, name, value, type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { computed } from "@ember/object";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { ArchetypeSchema } from "discourse/data/schemas/archetype";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
import { deepEqual } from "discourse/lib/object";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class Archetype extends RestModel {
|
||||
@computed("options.length")
|
||||
export default class Archetype extends RestCompatModel {
|
||||
static type = "archetype";
|
||||
|
||||
get hasOptions() {
|
||||
return this.options?.length > 0;
|
||||
}
|
||||
|
||||
@computed("id", "site.default_archetype")
|
||||
get isDefault() {
|
||||
return deepEqual(this.id, this.site?.default_archetype);
|
||||
}
|
||||
|
||||
@computed("isDefault")
|
||||
get notDefault() {
|
||||
return !this.isDefault;
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(Archetype, ArchetypeSchema);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { computed } from "@ember/object";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export function badgeGroupingDisplayName(name) {
|
||||
const i18nKey = `badges.badge_grouping.${name.toLowerCase().replace(/\s/g, "_")}.name`;
|
||||
return i18n(i18nKey, { defaultValue: name });
|
||||
}
|
||||
|
||||
export default class BadgeGrouping extends RestModel {
|
||||
@computed("name")
|
||||
get i18nNameKey() {
|
||||
@@ -10,7 +15,6 @@ export default class BadgeGrouping extends RestModel {
|
||||
|
||||
@computed("name")
|
||||
get displayName() {
|
||||
const i18nKey = `badges.badge_grouping.${this.i18nNameKey}.name`;
|
||||
return i18n(i18nKey, { defaultValue: this.name });
|
||||
return badgeGroupingDisplayName(this.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +1,42 @@
|
||||
import EmberObject, { computed, set } from "@ember/object";
|
||||
import { Promise } from "rsvp";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import {
|
||||
deleteBadge,
|
||||
findBadge,
|
||||
findBadges,
|
||||
saveBadge,
|
||||
} from "discourse/data/builders/badges";
|
||||
import { normalizeBadgesPayload } from "discourse/data/normalize";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { BadgeSchema } from "discourse/data/schemas/badge";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import BadgeGrouping from "discourse/models/badge-grouping";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class Badge extends RestModel {
|
||||
static createFromJson(json) {
|
||||
// Create BadgeType objects.
|
||||
const badgeTypes = {};
|
||||
if ("badge_types" in json) {
|
||||
json.badge_types.forEach(
|
||||
(badgeTypeJson) =>
|
||||
(badgeTypes[badgeTypeJson.id] = EmberObject.create(badgeTypeJson))
|
||||
);
|
||||
}
|
||||
export default class Badge extends RestCompatModel {
|
||||
static type = "badge";
|
||||
static normalize = normalizeBadgesPayload;
|
||||
static builders = {
|
||||
list: findBadges,
|
||||
one: findBadge,
|
||||
save: saveBadge,
|
||||
delete: deleteBadge,
|
||||
};
|
||||
|
||||
const badgeGroupings = {};
|
||||
if ("badge_groupings" in json) {
|
||||
json.badge_groupings.forEach(
|
||||
(badgeGroupingJson) =>
|
||||
(badgeGroupings[badgeGroupingJson.id] =
|
||||
BadgeGrouping.create(badgeGroupingJson))
|
||||
);
|
||||
}
|
||||
|
||||
// Create Badge objects.
|
||||
let badges = [];
|
||||
if ("badge" in json) {
|
||||
badges = [json.badge];
|
||||
} else if (json.badges) {
|
||||
badges = json.badges;
|
||||
}
|
||||
badges = badges.map((badgeJson) => {
|
||||
const badge = Badge.create(badgeJson);
|
||||
badge.setProperties({
|
||||
badge_type: badgeTypes[badge.badge_type_id],
|
||||
badge_grouping: badgeGroupings[badge.badge_grouping_id],
|
||||
});
|
||||
return badge;
|
||||
});
|
||||
|
||||
if ("badge" in json) {
|
||||
return badges[0];
|
||||
} else {
|
||||
return badges;
|
||||
}
|
||||
}
|
||||
|
||||
static findAll(opts) {
|
||||
let listable = "";
|
||||
if (opts && opts.onlyListable) {
|
||||
listable = "?only_listable=true";
|
||||
}
|
||||
|
||||
return ajax(`/badges.json${listable}`, { data: opts }).then((badgesJson) =>
|
||||
Badge.createFromJson(badgesJson)
|
||||
);
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return ajax(`/badges/${id}`).then((badgeJson) =>
|
||||
Badge.createFromJson(badgeJson)
|
||||
);
|
||||
}
|
||||
|
||||
@computed("id")
|
||||
get newBadge() {
|
||||
return this.id == null;
|
||||
}
|
||||
|
||||
@computed("image_url")
|
||||
// The `icon-or-image` helper reads `badge.image`.
|
||||
get image() {
|
||||
return this.image_url;
|
||||
}
|
||||
|
||||
set image(value) {
|
||||
set(this, "image_url", value);
|
||||
}
|
||||
|
||||
@computed
|
||||
get url() {
|
||||
return getURL(`/badges/${this.id}/${this.slug}`);
|
||||
}
|
||||
|
||||
updateFromJson(json) {
|
||||
if (json.badge) {
|
||||
Object.keys(json.badge).forEach((key) => this.set(key, json.badge[key]));
|
||||
}
|
||||
if (json.badge_types) {
|
||||
json.badge_types.forEach((badgeType) => {
|
||||
if (badgeType.id === this.badge_type_id) {
|
||||
this.set("badge_type", Object.create(badgeType));
|
||||
}
|
||||
});
|
||||
}
|
||||
get newBadge() {
|
||||
return this.id == null;
|
||||
}
|
||||
|
||||
@computed("badge_type.name")
|
||||
get badgeTypeClassName() {
|
||||
const type = this.badge_type?.name || "";
|
||||
return `badge-type-${type.toLowerCase()}`;
|
||||
}
|
||||
|
||||
save(data) {
|
||||
let url = "/admin/badges",
|
||||
type = "POST";
|
||||
|
||||
if (this.id) {
|
||||
// We are updating an existing badge.
|
||||
url += `/${this.id}`;
|
||||
type = "PUT";
|
||||
}
|
||||
|
||||
return ajax(url, { type, data }).then((json) => {
|
||||
this.updateFromJson(json);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.newBadge) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return ajax(`/admin/badges/${this.id}`, {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(Badge, BadgeSchema);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { computed } from "@ember/object";
|
||||
import { capitalize } from "@ember/string";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import { Promise } from "rsvp";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import {
|
||||
bulkBookmarkOperation,
|
||||
deleteBookmark,
|
||||
togglePinBookmark,
|
||||
} from "discourse/data/builders/bookmarks";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { BookmarkSchema } from "discourse/data/schemas/bookmark";
|
||||
import {
|
||||
defineFieldForwarders,
|
||||
warpStore,
|
||||
} from "discourse/data/warp-rest-model";
|
||||
import { formattedReminderTime } from "discourse/lib/bookmark";
|
||||
import { longDate } from "discourse/lib/formatter";
|
||||
import { getOwnerWithFallback } from "discourse/lib/get-owner";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import { applyModelTransformations } from "discourse/lib/model-transformers";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import Topic from "discourse/models/topic";
|
||||
import User from "discourse/models/user";
|
||||
import { i18n } from "discourse-i18n";
|
||||
@@ -24,12 +33,22 @@ export const NO_REMINDER_ICON = "bookmark";
|
||||
export const NOT_BOOKMARKED = "far-bookmark";
|
||||
export const WITH_REMINDER_ICON = "discourse-bookmark-clock";
|
||||
|
||||
export default class Bookmark extends RestModel {
|
||||
static create(args) {
|
||||
args = args || {};
|
||||
args.currentUser = args.currentUser || User.current();
|
||||
args.user = User.create(args.user);
|
||||
return super.create(args);
|
||||
export default class Bookmark extends RestCompatModel {
|
||||
static type = "bookmark";
|
||||
static builders = { delete: deleteBookmark };
|
||||
|
||||
// `user` is wrapped as a User instance (legacy parity); `currentUser` is
|
||||
// stashed on the wrapper as an own property (survives `_adoptResource` and
|
||||
// isn't part of the schema).
|
||||
static create(args = {}) {
|
||||
if (args instanceof Bookmark) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const { user, currentUser, ...rest } = args;
|
||||
const wrapper = super.create({ ...rest, user: User.create(user) });
|
||||
wrapper.currentUser = currentUser || User.current();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
static createFor(user, bookmarkableType, bookmarkableId) {
|
||||
@@ -42,41 +61,30 @@ export default class Bookmark extends RestModel {
|
||||
}
|
||||
|
||||
static bulkOperation(bookmarks, operation) {
|
||||
const data = {
|
||||
bookmark_ids: bookmarks.map((item) => item.id),
|
||||
operation,
|
||||
};
|
||||
|
||||
return ajax("/bookmarks/bulk", {
|
||||
type: "PUT",
|
||||
data,
|
||||
});
|
||||
return warpStore().request(
|
||||
bulkBookmarkOperation(
|
||||
bookmarks.map((b) => b.id),
|
||||
operation
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static async applyTransformations(bookmarks) {
|
||||
await applyModelTransformations("bookmark", bookmarks);
|
||||
}
|
||||
|
||||
@computed("id")
|
||||
#topicForList;
|
||||
|
||||
#siteSettings;
|
||||
|
||||
get newBookmark() {
|
||||
return this.id == null;
|
||||
}
|
||||
|
||||
@computed
|
||||
get url() {
|
||||
return getURL(`/bookmarks/${this.id}`);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.newBookmark) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return ajax(this.url, {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
attachedTo() {
|
||||
return {
|
||||
target: this.bookmarkable_type.toLowerCase(),
|
||||
@@ -88,17 +96,13 @@ export default class Bookmark extends RestModel {
|
||||
if (this.newBookmark) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return ajax(this.url + "/toggle_pin", {
|
||||
type: "PUT",
|
||||
});
|
||||
return warpStore().request(togglePinBookmark(this.id));
|
||||
}
|
||||
|
||||
pinAction() {
|
||||
return this.pinned ? "unpin" : "pin";
|
||||
}
|
||||
|
||||
@computed("topic_id", "highest_post_number", "bookmarkable_url")
|
||||
get lastPostUrl() {
|
||||
return this.topic_id
|
||||
? this.urlForPostNumber(this.highest_post_number)
|
||||
@@ -113,12 +117,10 @@ export default class Bookmark extends RestModel {
|
||||
return url;
|
||||
}
|
||||
|
||||
@computed("bumped_at", "createdAt")
|
||||
get bumpedAt() {
|
||||
return this.bumped_at ? new Date(this.bumped_at) : this.createdAt;
|
||||
}
|
||||
|
||||
@computed("bumpedAt", "createdAt")
|
||||
get bumpedAtTitle() {
|
||||
const BUMPED_FORMAT = "YYYY-MM-DDTHH:mm:ss";
|
||||
if (moment(this.bumpedAt).isValid() && moment(this.createdAt).isValid()) {
|
||||
@@ -133,14 +135,14 @@ export default class Bookmark extends RestModel {
|
||||
}
|
||||
}
|
||||
|
||||
@computed("name", "reminder_at")
|
||||
get timezone() {
|
||||
return this.currentUser?.user_option?.timezone || moment.tz.guess();
|
||||
}
|
||||
|
||||
get reminderTitle() {
|
||||
if (!isEmpty(this.reminder_at)) {
|
||||
return i18n("bookmarks.created_with_reminder_generic", {
|
||||
date: formattedReminderTime(
|
||||
this.reminder_at,
|
||||
this.currentUser?.user_option?.timezone || moment.tz.guess()
|
||||
),
|
||||
date: formattedReminderTime(this.reminder_at, this.timezone),
|
||||
name: this.name || "",
|
||||
});
|
||||
}
|
||||
@@ -150,73 +152,59 @@ export default class Bookmark extends RestModel {
|
||||
});
|
||||
}
|
||||
|
||||
@computed("created_at")
|
||||
get createdAt() {
|
||||
return new Date(this.created_at);
|
||||
}
|
||||
|
||||
@computed("tags")
|
||||
// Read per row on the bookmark list — cache the service lookup.
|
||||
get visibleListTags() {
|
||||
if (!this.tags || !this.siteSettings.suppress_overlapping_tags_in_list) {
|
||||
return this.tags;
|
||||
const tags = this.tags;
|
||||
this.#siteSettings ??= getOwnerWithFallback().lookup(
|
||||
"service:site-settings"
|
||||
);
|
||||
if (!tags || !this.#siteSettings.suppress_overlapping_tags_in_list) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
const title = this.title;
|
||||
const newTags = [];
|
||||
|
||||
this.tags.forEach(function (tag) {
|
||||
if (!title.toLowerCase().includes(tag)) {
|
||||
newTags.push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
return newTags;
|
||||
const title = this.title.toLowerCase();
|
||||
return tags.filter((tag) => !title.includes(tag));
|
||||
}
|
||||
|
||||
@computed("category_id")
|
||||
get category() {
|
||||
return Category.findById(this.category_id);
|
||||
}
|
||||
|
||||
@computed("reminder_at", "currentUser")
|
||||
get formattedReminder() {
|
||||
return capitalize(
|
||||
formattedReminderTime(
|
||||
this.reminder_at,
|
||||
this.currentUser?.user_option?.timezone || moment.tz.guess()
|
||||
)
|
||||
);
|
||||
return capitalize(formattedReminderTime(this.reminder_at, this.timezone));
|
||||
}
|
||||
|
||||
@computed("reminder_at")
|
||||
get reminderAtExpired() {
|
||||
return moment(this.reminder_at) < moment();
|
||||
}
|
||||
|
||||
@computed()
|
||||
// For topic-level bookmarks, no linked post number — let the topic-link
|
||||
// helper jump to the last unread post by default.
|
||||
//
|
||||
// Built once: read from a list row, where a fresh `Topic` per read would fire
|
||||
// `init` callbacks on every rerender and hand out a new identity each time.
|
||||
get topicForList() {
|
||||
// for topic level bookmarks we want to jump to the last unread post URL,
|
||||
// which the topic-link helper does by default if no linked post number is
|
||||
// provided
|
||||
const linkedPostNumber =
|
||||
this.bookmarkable_type === "Topic" ? null : this.linked_post_number;
|
||||
|
||||
return Topic.create({
|
||||
return (this.#topicForList ??= Topic.create({
|
||||
id: this.topic_id,
|
||||
fancy_title: this.fancy_title,
|
||||
linked_post_number: linkedPostNumber,
|
||||
linked_post_number:
|
||||
this.bookmarkable_type === "Topic" ? null : this.linked_post_number,
|
||||
last_read_post_number: this.last_read_post_number,
|
||||
highest_post_number: this.highest_post_number,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@computed("bookmarkable_type")
|
||||
get bookmarkableTopicAlike() {
|
||||
return ["Topic", "Post"].includes(this.bookmarkable_type);
|
||||
}
|
||||
|
||||
@computed("reminder_at", "name")
|
||||
get hasMetadata() {
|
||||
return this.reminder_at || this.name;
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(Bookmark, BookmarkSchema);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { computed } from "@ember/object";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TagGroupSchema } from "discourse/data/schemas/tag-group";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
import PermissionType from "discourse/models/permission-type";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class TagGroup extends RestModel {
|
||||
@computed("permissions")
|
||||
export default class TagGroup extends RestCompatModel {
|
||||
static type = "tag-group";
|
||||
|
||||
get permissionName() {
|
||||
if (!this.permissions) {
|
||||
return "public";
|
||||
@@ -18,3 +20,5 @@ export default class TagGroup extends RestModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(TagGroup, TagGroupSchema);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { autoTrackedArray } from "discourse/lib/tracked-tools";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TagInfoSchema } from "discourse/data/schemas/tag-info";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
|
||||
export default class TagInfo extends RestModel {
|
||||
@tracked category_restricted;
|
||||
@tracked description;
|
||||
@tracked id;
|
||||
@tracked name;
|
||||
@tracked slug;
|
||||
@tracked staff;
|
||||
@tracked topic_count;
|
||||
|
||||
@autoTrackedArray categories;
|
||||
@autoTrackedArray localizations;
|
||||
@autoTrackedArray synonyms;
|
||||
@autoTrackedArray tag_group_names;
|
||||
export default class TagInfo extends RestCompatModel {
|
||||
static type = "tag-info";
|
||||
}
|
||||
|
||||
defineFieldForwarders(TagInfo, TagInfoSchema);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TagNotificationSchema } from "discourse/data/schemas/tag-notification";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
|
||||
export default class TagNotification extends RestModel {
|
||||
export default class TagNotification extends RestCompatModel {
|
||||
static type = "tag-notification";
|
||||
primaryKey = "name";
|
||||
}
|
||||
|
||||
defineFieldForwarders(TagNotification, TagNotificationSchema);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TagSettingsSchema } from "discourse/data/schemas/tag-settings";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
|
||||
export default class TagSettings extends RestModel {}
|
||||
export default class TagSettings extends RestCompatModel {
|
||||
static type = "tag-settings";
|
||||
}
|
||||
|
||||
defineFieldForwarders(TagSettings, TagSettingsSchema);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { computed } from "@ember/object";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TagSchema } from "discourse/data/schemas/tag";
|
||||
import { defineFieldForwarders } from "discourse/data/warp-rest-model";
|
||||
import getURL from "discourse/lib/get-url";
|
||||
import RestModel from "discourse/models/rest";
|
||||
|
||||
export default class Tag extends RestModel {
|
||||
@computed("pm_only")
|
||||
export default class Tag extends RestCompatModel {
|
||||
static type = "tag";
|
||||
|
||||
get pmOnly() {
|
||||
return this.pm_only;
|
||||
}
|
||||
|
||||
@computed("slug", "id")
|
||||
get url() {
|
||||
if (this.id) {
|
||||
const slugForUrl = this.slug || `${this.id}-tag`;
|
||||
@@ -18,19 +19,18 @@ export default class Tag extends RestModel {
|
||||
return getURL(`/tag/${this.name.replaceAll(".", "%2E")}`);
|
||||
}
|
||||
|
||||
@computed("count", "pm_count")
|
||||
get totalCount() {
|
||||
return this.pm_count ? this.count + this.pm_count : this.count;
|
||||
}
|
||||
|
||||
@computed("id", "name")
|
||||
get searchContext() {
|
||||
return {
|
||||
type: "tag",
|
||||
id: this.id,
|
||||
/** @type Tag */
|
||||
tag: this,
|
||||
tag: /** @type {Tag} */ (this),
|
||||
name: this.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(Tag, TagSchema);
|
||||
|
||||
@@ -1,90 +1,143 @@
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import EmberObject from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { removeValueFromArray } from "discourse/lib/array-tools";
|
||||
import { autoTrackedArray } from "discourse/lib/tracked-tools";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import {
|
||||
removeAllowedTopicGroup,
|
||||
removeAllowedTopicUser,
|
||||
updateTopicNotificationLevel,
|
||||
} from "discourse/data/builders/topic-details";
|
||||
import { normalizeTopicDetailsPayload } from "discourse/data/normalize";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { TopicDetailsSchema } from "discourse/data/schemas/topic-details";
|
||||
import {
|
||||
defineFieldForwarders,
|
||||
warpStore,
|
||||
} from "discourse/data/warp-rest-model";
|
||||
import User from "discourse/models/user";
|
||||
|
||||
/**
|
||||
A model representing a Topic's details that aren't always present, such as a list of participants.
|
||||
When showing topics in lists and such this information should not be required.
|
||||
**/
|
||||
export default class TopicDetails extends RestCompatModel {
|
||||
static type = "topic-details";
|
||||
|
||||
export default class TopicDetails extends RestModel {
|
||||
@service store;
|
||||
// `store.createRecord("topicDetails", { id, topic, ...attrs })` entry point.
|
||||
// Extras (e.g. tests doing `topic.details = { allowed_users: [...] }`) land
|
||||
// in `#draft` until `updateFromJson` populates the cache record.
|
||||
static create({ id, topic, ...rest } = {}) {
|
||||
const td = new this(id);
|
||||
if (topic !== undefined) {
|
||||
td.topic = topic;
|
||||
}
|
||||
if (Object.keys(rest).length > 0) {
|
||||
td.#draft = rest;
|
||||
}
|
||||
return td;
|
||||
}
|
||||
|
||||
@tracked can_delete;
|
||||
@tracked can_edit_staff_notes;
|
||||
@tracked can_permanently_delete;
|
||||
@tracked can_publish_page;
|
||||
@tracked can_split_merge_topic;
|
||||
@tracked created_by;
|
||||
@tracked notification_level;
|
||||
@autoTrackedArray allowed_groups;
|
||||
@autoTrackedArray allowed_users;
|
||||
@tracked loaded = false;
|
||||
topic = null;
|
||||
|
||||
loaded = false;
|
||||
#topicId;
|
||||
#cachedRecord;
|
||||
#draft = {};
|
||||
|
||||
constructor(topicId) {
|
||||
super();
|
||||
this.#topicId = topicId == null ? null : String(topicId);
|
||||
}
|
||||
|
||||
// Topic's `_details` field initializer runs before Topic.id is assigned, so
|
||||
// back-fill lazily from `this.topic.id` on first access.
|
||||
#effectiveTopicId() {
|
||||
if (this.#topicId == null && this.topic?.id != null) {
|
||||
this.#topicId = String(this.topic.id);
|
||||
}
|
||||
return this.#topicId;
|
||||
}
|
||||
|
||||
// `#topicId in this` is only true once `super()` has returned and our own
|
||||
// fields and private methods are installed. The base constructor fires plugin
|
||||
// `init` callbacks, which can reach `id` / `__resource` before that — and
|
||||
// touching anything private that early throws. The check has to be inline:
|
||||
// hiding it behind a private helper would trip the same trap.
|
||||
get id() {
|
||||
return #topicId in this ? this.#effectiveTopicId() : null;
|
||||
}
|
||||
|
||||
// Falls back to `#draft` so attrs set via `create({...})` (or assigned
|
||||
// before `updateFromJson`) are readable/writable. The cached record is
|
||||
// memoized (identity is stable per type + id): this getter backs every field
|
||||
// forwarder, and topic templates read those on every render.
|
||||
get __resource() {
|
||||
// See `id` — nothing private is reachable until `super()` has returned.
|
||||
if (!(#topicId in this)) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.#cachedRecord) {
|
||||
return this.#cachedRecord;
|
||||
}
|
||||
const id = this.#effectiveTopicId();
|
||||
if (id != null) {
|
||||
const cached = warpStore().peekRecord({
|
||||
type: "topic-details",
|
||||
id,
|
||||
});
|
||||
if (cached) {
|
||||
return (this.#cachedRecord = cached);
|
||||
}
|
||||
}
|
||||
return this.#draft;
|
||||
}
|
||||
|
||||
// Wraps user/participant sideloads to match legacy RestModel behavior so
|
||||
// `instanceof User` and EmberObject methods keep working.
|
||||
updateFromJson(details) {
|
||||
const topic = this.topic;
|
||||
|
||||
const id = this.#effectiveTopicId();
|
||||
if (id == null || !details) {
|
||||
return;
|
||||
}
|
||||
const wrapped = { ...details };
|
||||
if (details.allowed_users) {
|
||||
details.allowed_users = details.allowed_users.map((u) =>
|
||||
this.store.createRecord("user", u)
|
||||
wrapped.allowed_users = details.allowed_users.map((u) => User.create(u));
|
||||
}
|
||||
if (details.participants) {
|
||||
const topic = this.topic;
|
||||
wrapped.participants = details.participants.map((p) =>
|
||||
EmberObject.create({ ...p, topic })
|
||||
);
|
||||
}
|
||||
|
||||
if (details.participants) {
|
||||
details.participants = details.participants.map((p) => {
|
||||
p.topic = topic;
|
||||
return EmberObject.create(p);
|
||||
});
|
||||
}
|
||||
|
||||
this.setProperties(details);
|
||||
this.set("loaded", true);
|
||||
const store = warpStore();
|
||||
store.push(normalizeTopicDetailsPayload({ topicId: id, details: wrapped }));
|
||||
this._applyExtraAttributes(id);
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
updateNotifications(level) {
|
||||
return ajax(`/t/${this.get("topic.id")}/notifications`, {
|
||||
type: "POST",
|
||||
data: { notification_level: level },
|
||||
}).then(() => {
|
||||
this.setProperties({
|
||||
notification_level: level,
|
||||
notifications_reason_id: null,
|
||||
});
|
||||
const store = warpStore();
|
||||
const id = this.#effectiveTopicId();
|
||||
return store.request(updateTopicNotificationLevel(id, level)).then(() => {
|
||||
this.notification_level = level;
|
||||
this.notifications_reason_id = null;
|
||||
});
|
||||
}
|
||||
|
||||
async removeAllowedGroup(group) {
|
||||
const groups = this.allowed_groups;
|
||||
const name = group.name;
|
||||
|
||||
await ajax(`/t/${this.get("topic.id")}/remove-allowed-group`, {
|
||||
type: "PUT",
|
||||
data: { name },
|
||||
});
|
||||
|
||||
removeValueFromArray(
|
||||
groups,
|
||||
groups.find((item) => item.name === name)
|
||||
const store = warpStore();
|
||||
await store.request(
|
||||
removeAllowedTopicGroup(this.#effectiveTopicId(), group.name)
|
||||
);
|
||||
this.allowed_groups = this.allowed_groups.filter(
|
||||
(g) => g.name !== group.name
|
||||
);
|
||||
}
|
||||
|
||||
async removeAllowedUser(user) {
|
||||
const users = this.allowed_users;
|
||||
const username = user.get("username");
|
||||
|
||||
await ajax(`/t/${this.get("topic.id")}/remove-allowed-user`, {
|
||||
type: "PUT",
|
||||
data: { username },
|
||||
});
|
||||
|
||||
removeValueFromArray(
|
||||
users,
|
||||
users.find((item) => item.username === username)
|
||||
const username = user.username ?? user.get?.("username");
|
||||
const store = warpStore();
|
||||
await store.request(
|
||||
removeAllowedTopicUser(this.#effectiveTopicId(), username)
|
||||
);
|
||||
this.allowed_users = this.allowed_users.filter(
|
||||
(u) => u.username !== username
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(TopicDetails, TopicDetailsSchema);
|
||||
|
||||
@@ -1,168 +1,113 @@
|
||||
import EmberObject, { computed } from "@ember/object";
|
||||
import { Promise } from "rsvp";
|
||||
import {
|
||||
findUserBadgesByBadgeId,
|
||||
findUserBadgesByUsername,
|
||||
grantUserBadge,
|
||||
toggleFavoriteUserBadge,
|
||||
} from "discourse/data/builders/user-badges";
|
||||
import { normalizeUserBadgesPayload } from "discourse/data/normalize";
|
||||
import RestCompatModel from "discourse/data/rest-compat";
|
||||
import { UserBadgeSchema } from "discourse/data/schemas/user-badge";
|
||||
import {
|
||||
defineFieldForwarders,
|
||||
requestMany,
|
||||
requestOne,
|
||||
warpStore,
|
||||
} from "discourse/data/warp-rest-model";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Badge from "discourse/models/badge";
|
||||
import Topic from "discourse/models/topic";
|
||||
import User from "discourse/models/user";
|
||||
|
||||
export default class UserBadge extends EmberObject {
|
||||
static createFromJson(json) {
|
||||
// Create User objects.
|
||||
if (json.users === undefined) {
|
||||
json.users = [];
|
||||
}
|
||||
let users = {};
|
||||
json.users.forEach(function (userJson) {
|
||||
users[userJson.id] = User.create(userJson);
|
||||
});
|
||||
export default class UserBadge extends RestCompatModel {
|
||||
static type = "user-badge";
|
||||
static normalize = normalizeUserBadgesPayload;
|
||||
|
||||
json.granted_bies = json.granted_bies ?? [];
|
||||
json.granted_bies.forEach(function (userJson) {
|
||||
users[userJson.id] = User.create(userJson);
|
||||
});
|
||||
|
||||
// Create Topic objects.
|
||||
if (json.topics === undefined) {
|
||||
json.topics = [];
|
||||
}
|
||||
let topics = {};
|
||||
json.topics.forEach(function (topicJson) {
|
||||
topics[topicJson.id] = Topic.create(topicJson);
|
||||
});
|
||||
|
||||
// Create the badges.
|
||||
if (json.badges === undefined) {
|
||||
json.badges = [];
|
||||
}
|
||||
let badges = {};
|
||||
Badge.createFromJson(json).forEach(function (badge) {
|
||||
badges[badge.get("id")] = badge;
|
||||
});
|
||||
|
||||
// Create UserBadge object(s).
|
||||
let userBadges;
|
||||
if ("user_badge" in json) {
|
||||
userBadges = [json.user_badge];
|
||||
} else {
|
||||
userBadges =
|
||||
(json.user_badge_info && json.user_badge_info.user_badges) ||
|
||||
json.user_badges;
|
||||
}
|
||||
|
||||
userBadges = userBadges.map(function (userBadgeJson) {
|
||||
let userBadge = UserBadge.create(userBadgeJson);
|
||||
|
||||
let grantedAtDate = Date.parse(userBadge.get("granted_at"));
|
||||
userBadge.set("grantedAt", grantedAtDate);
|
||||
|
||||
userBadge.set("badge", badges[userBadge.get("badge_id")]);
|
||||
if (userBadge.get("user_id")) {
|
||||
userBadge.set("user", users[userBadge.get("user_id")]);
|
||||
}
|
||||
if (userBadge.get("granted_by_id")) {
|
||||
userBadge.set("granted_by", users[userBadge.get("granted_by_id")]);
|
||||
}
|
||||
if (userBadge.get("topic_id")) {
|
||||
userBadge.set("topic", topics[userBadge.get("topic_id")]);
|
||||
}
|
||||
return userBadge;
|
||||
});
|
||||
|
||||
if ("user_badge" in json) {
|
||||
return userBadges[0];
|
||||
} else {
|
||||
if (json.user_badge_info) {
|
||||
userBadges.grant_count = json.user_badge_info.grant_count;
|
||||
userBadges.username = json.user_badge_info.username;
|
||||
}
|
||||
return userBadges;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Find all badges for a given username.
|
||||
|
||||
@method findByUsername
|
||||
@param {String} username
|
||||
@param {Object} options
|
||||
@returns {Promise} a promise that resolves to an array of `UserBadge`.
|
||||
**/
|
||||
static findByUsername(username, options) {
|
||||
// Async so callers can `.then` even on the no-username short circuit
|
||||
// (`badges.show` passes a null username for anonymous visitors).
|
||||
static async findByUsername(username, options = {}) {
|
||||
if (!username) {
|
||||
return Promise.resolve([]);
|
||||
return [];
|
||||
}
|
||||
let url = "/user-badges/" + username + ".json";
|
||||
if (options && options.grouped) {
|
||||
url += "?grouped=true";
|
||||
}
|
||||
return ajax(url).then(function (json) {
|
||||
return UserBadge.createFromJson(json);
|
||||
});
|
||||
return requestMany(this, findUserBadgesByUsername(username, options));
|
||||
}
|
||||
|
||||
/**
|
||||
Find all badge grants for a given badge ID.
|
||||
|
||||
@method findById
|
||||
@param {String} badgeId
|
||||
@returns {Promise} a promise that resolves to an array of `UserBadge`.
|
||||
**/
|
||||
static findByBadgeId(badgeId, options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
options.badge_id = badgeId;
|
||||
|
||||
return ajax("/user_badges.json", {
|
||||
data: options,
|
||||
}).then(function (json) {
|
||||
return UserBadge.createFromJson(json);
|
||||
});
|
||||
static findByBadgeId(badgeId, options = {}) {
|
||||
return requestMany(this, findUserBadgesByBadgeId(badgeId, options));
|
||||
}
|
||||
|
||||
/**
|
||||
Grant the badge having id `badgeId` to the user identified by `username`.
|
||||
|
||||
@method grant
|
||||
@param {Integer} badgeId id of the badge to be granted.
|
||||
@param {String} username username of the user to be granted the badge.
|
||||
@returns {Promise} a promise that resolves to an instance of `UserBadge`.
|
||||
**/
|
||||
static grant(badgeId, username, reason) {
|
||||
return ajax("/user_badges", {
|
||||
type: "POST",
|
||||
data: {
|
||||
username,
|
||||
badge_id: badgeId,
|
||||
reason,
|
||||
},
|
||||
}).then(function (json) {
|
||||
return UserBadge.createFromJson(json);
|
||||
return requestOne(this, grantUserBadge(badgeId, username, reason));
|
||||
}
|
||||
|
||||
#badge;
|
||||
#badgeResource;
|
||||
|
||||
// Wraps the cached resource in a `Badge` so callers can read its computed
|
||||
// getters (`.url`, `.badgeTypeClassName`, ...). Memoized against that
|
||||
// resource — a fresh instance per read would fire `init` callbacks and break
|
||||
// identity — while the unconditional read keeps its tracked tag consumed.
|
||||
get badge() {
|
||||
const resource = this.__resource?.badge;
|
||||
if (resource !== this.#badgeResource) {
|
||||
this.#badgeResource = resource;
|
||||
this.#badge = resource ? new Badge(resource) : undefined;
|
||||
}
|
||||
return this.#badge;
|
||||
}
|
||||
|
||||
// Getter: null → undefined (test contract).
|
||||
get granted_by() {
|
||||
return this.__resource?.granted_by ?? undefined;
|
||||
}
|
||||
|
||||
// Setter: shadows on the wrapper so admin's read-then-write `groupedBadges`
|
||||
// doesn't trip Glimmer.
|
||||
set granted_by(value) {
|
||||
Object.defineProperty(this, "granted_by", {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
@computed
|
||||
get grantedAt() {
|
||||
return this.granted_at ? Date.parse(this.granted_at) : null;
|
||||
}
|
||||
|
||||
get postUrl() {
|
||||
if (this.topic_title) {
|
||||
return "/t/-/" + this.topic_id + "/" + this.post_number;
|
||||
return `/t/-/${this.topic_id}/${this.post_number}`;
|
||||
}
|
||||
} // avoid the extra bindings for now
|
||||
|
||||
revoke() {
|
||||
return ajax("/user_badges/" + this.id, {
|
||||
type: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
favorite() {
|
||||
this.toggleProperty("is_favorite");
|
||||
return ajax(`/user_badges/${this.id}/toggle_favorite`, {
|
||||
type: "PUT",
|
||||
}).catch((e) => {
|
||||
// something went wrong, switch the UI back:
|
||||
this.toggleProperty("is_favorite");
|
||||
popupAjaxError(e);
|
||||
// Direct ajax so admin callers can read the response body.
|
||||
revoke() {
|
||||
return ajax(`/user_badges/${this.id}`, { type: "DELETE" });
|
||||
}
|
||||
|
||||
async favorite() {
|
||||
const store = warpStore();
|
||||
const previous = this.is_favorite;
|
||||
const partial = (value) => ({
|
||||
data: {
|
||||
type: "user-badge",
|
||||
id: String(this.id),
|
||||
attributes: { is_favorite: value },
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic flip. `_adoptResource` swaps a draft wrapper to the now-
|
||||
// cached record so the new value is visible.
|
||||
store.push(partial(!previous));
|
||||
this._adoptResource(this.id);
|
||||
|
||||
try {
|
||||
await store.request(toggleFavoriteUserBadge(this.id));
|
||||
} catch (e) {
|
||||
store.push(partial(previous));
|
||||
popupAjaxError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineFieldForwarders(UserBadge, UserBadgeSchema);
|
||||
|
||||
@@ -152,23 +152,24 @@ export default class TopicFromParams extends DiscourseRoute {
|
||||
const topic = this.modelFor("topic");
|
||||
const postStream = topic.postStream;
|
||||
|
||||
// TODO we are seeing errors where closest post is null and this is exploding
|
||||
// we need better handling and logging for this condition.
|
||||
|
||||
// there are no closestPost for hidden topics
|
||||
if (topic.view_hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The post we requested might not exist. Let's find the closest post
|
||||
// The post we requested might not exist. Let's find the closest post —
|
||||
// a topic with no posts at all has none, so everything keyed off a post
|
||||
// is skipped below while the route itself still sets up.
|
||||
const closestPost = postStream.closestPostForPostNumber(
|
||||
params.nearPost || 1
|
||||
);
|
||||
const closest = closestPost.post_number;
|
||||
const closest = closestPost?.post_number;
|
||||
|
||||
topicController.setProperties({
|
||||
"model.currentPost": closest,
|
||||
enteredIndex: topic.postStream.progressIndexOfPost(closestPost),
|
||||
enteredIndex: closestPost
|
||||
? topic.postStream.progressIndexOfPost(closestPost)
|
||||
: undefined,
|
||||
enteredAt: Date.now().toString(),
|
||||
userLastReadPostNumber: topic.last_read_post_number,
|
||||
highestPostNumber: topic.highest_post_number,
|
||||
@@ -181,23 +182,25 @@ export default class TopicFromParams extends DiscourseRoute {
|
||||
this.screenTrack.start(topic.id, topicController);
|
||||
}
|
||||
|
||||
// Highlight our post after the next render
|
||||
schedule("afterRender", () =>
|
||||
this.appEvents.trigger("post:highlight", closest)
|
||||
);
|
||||
if (closestPost) {
|
||||
// Highlight our post after the next render
|
||||
schedule("afterRender", () =>
|
||||
this.appEvents.trigger("post:highlight", closest)
|
||||
);
|
||||
|
||||
const opts = {};
|
||||
if (document.location.hash) {
|
||||
opts.anchor = document.location.hash.slice(1);
|
||||
} else if (_discourse_anchor) {
|
||||
opts.anchor = _discourse_anchor;
|
||||
}
|
||||
DiscourseURL.jumpToPost(closest, opts);
|
||||
const opts = {};
|
||||
if (document.location.hash) {
|
||||
opts.anchor = document.location.hash.slice(1);
|
||||
} else if (_discourse_anchor) {
|
||||
opts.anchor = _discourse_anchor;
|
||||
}
|
||||
DiscourseURL.jumpToPost(closest, opts);
|
||||
|
||||
// completely clear out all the bookmark related attributes
|
||||
// because they are not in the response if bookmarked == false
|
||||
if (closestPost && !closestPost.bookmarked) {
|
||||
closestPost.clearBookmark();
|
||||
// completely clear out all the bookmark related attributes
|
||||
// because they are not in the response if bookmarked == false
|
||||
if (!closestPost.bookmarked) {
|
||||
closestPost.clearBookmark();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEmpty(topic.draft) && !EmbedMode.enabled) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { JSONAPICache } from "@warp-drive/json-api";
|
||||
import { useLegacyStore } from "@warp-drive/legacy";
|
||||
import discourseRestHandler from "discourse/data/handlers/discourse-rest";
|
||||
import { schemas } from "discourse/data/schemas";
|
||||
|
||||
// `linksMode: true` skips the LegacyNetworkHandler so our handler is the
|
||||
// sole network layer (routes through Discourse's `ajax()` helper).
|
||||
export default class WarpStore extends useLegacyStore({
|
||||
cache: JSONAPICache,
|
||||
schemas,
|
||||
handlers: [discourseRestHandler],
|
||||
linksMode: true,
|
||||
}) {}
|
||||
@@ -1,4 +1,7 @@
|
||||
const { buildMacros } = require("@embroider/macros/babel");
|
||||
const {
|
||||
setConfig: setWarpDriveConfig,
|
||||
} = require("@warp-drive/core/build-config");
|
||||
const StripTestSelectors = require("strip-test-selectors");
|
||||
|
||||
const macros = buildMacros({
|
||||
@@ -6,6 +9,10 @@ const macros = buildMacros({
|
||||
macrosConfig.setGlobalConfig(__filename, "@embroider/core", {
|
||||
active: true,
|
||||
});
|
||||
// WarpDrive build config (previously wired via setConfig in ember-cli-build.js).
|
||||
// Feeds into our existing @embroider/macros config rather than a second
|
||||
// instance, which would duplicate the macros babel plugin.
|
||||
setWarpDriveConfig(macrosConfig, { compatWith: "5.9" });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/lr": "^1.4.8",
|
||||
"@warp-drive/core": "5.9.0-alpha.15",
|
||||
"@warp-drive/ember": "5.9.0-alpha.15",
|
||||
"@warp-drive/json-api": "5.9.0-alpha.15",
|
||||
"@warp-drive/legacy": "5.9.0-alpha.15",
|
||||
"@warp-drive/utilities": "5.9.0-alpha.15",
|
||||
"ace-builds": "^1.44.0",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
|
||||
@@ -19,6 +19,7 @@ import "message-bus-client";
|
||||
import * as FakerModule from "@faker-js/faker";
|
||||
import QUnit from "qunit";
|
||||
import sinon from "sinon";
|
||||
import { clearExtraAttributes } from "discourse/data/extra-attributes";
|
||||
import { setDefaultOwner } from "discourse/lib/get-owner";
|
||||
import { setupS3CDN, setupURL } from "discourse/lib/get-url";
|
||||
import { setLoadedFaker } from "discourse/lib/load-faker";
|
||||
@@ -350,6 +351,7 @@ export default async function setupTests(config) {
|
||||
testContainer.scrollLeft = 0;
|
||||
|
||||
flushMap();
|
||||
clearExtraAttributes();
|
||||
|
||||
window.MessageBus.unsubscribe("*");
|
||||
localStorage.clear();
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { addObserver } from "@ember/object/observers";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { settled } from "@ember/test-helpers";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import { rollbackAllPrepends } from "discourse/lib/class-prepend";
|
||||
import {
|
||||
modelNameFor,
|
||||
resetModelExtensions,
|
||||
} from "discourse/lib/model-extensions";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import { isTrackedArray } from "discourse/lib/tracked-tools";
|
||||
import Badge from "discourse/models/badge";
|
||||
import pretender, {
|
||||
parsePostData,
|
||||
response,
|
||||
} from "discourse/tests/helpers/create-pretender";
|
||||
|
||||
// Mirrors `model-extensions-test.js`, but exercises the extension APIs against a
|
||||
// WarpDrive-backed model (`badge`) rather than a `RestModel`.
|
||||
module("Unit | Lib | model-extensions (WarpDrive)", function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.store = getOwner(this).lookup("service:store");
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
rollbackAllPrepends();
|
||||
resetModelExtensions();
|
||||
});
|
||||
|
||||
test("modelNameFor resolves via `static type` without stamping", function (assert) {
|
||||
const badge = this.store.createRecord("badge", { name: "a" });
|
||||
assert.strictEqual(modelNameFor(badge), "badge");
|
||||
});
|
||||
|
||||
test("addModelField uses the default until a server value overrides it", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "rank", { defaultValue: 5 })
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "a" });
|
||||
assert.strictEqual(record.rank, 5, "falls back to the default");
|
||||
|
||||
record.rank = 9;
|
||||
assert.strictEqual(record.rank, 9, "the field is writable and tracked");
|
||||
|
||||
const fromServer = this.store.createRecord("badge", { name: "b", rank: 7 });
|
||||
assert.strictEqual(fromServer.rank, 7, "a server value wins");
|
||||
});
|
||||
|
||||
test("addModelField type array gives per-instance tracked arrays", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "extras", { type: "array", defaultValue: [] })
|
||||
);
|
||||
|
||||
const a = this.store.createRecord("badge", { name: "a" });
|
||||
const b = this.store.createRecord("badge", { name: "b" });
|
||||
|
||||
assert.true(isTrackedArray(a.extras), "the default is a tracked array");
|
||||
|
||||
a.extras.push("x");
|
||||
assert.deepEqual([...a.extras], ["x"]);
|
||||
assert.deepEqual([...b.extras], [], "each record gets its own array");
|
||||
|
||||
a.extras = ["y", "z"];
|
||||
assert.true(isTrackedArray(a.extras), "an assigned plain array is coerced");
|
||||
assert.deepEqual([...a.extras], ["y", "z"]);
|
||||
});
|
||||
|
||||
test("addModelField type array coerces a server-provided array", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "extras", { type: "array" })
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", {
|
||||
name: "a",
|
||||
extras: ["p", "q"],
|
||||
});
|
||||
|
||||
assert.true(isTrackedArray(record.extras));
|
||||
assert.deepEqual([...record.extras], ["p", "q"]);
|
||||
});
|
||||
|
||||
test("addModelField type object gives a per-instance tracked object", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "bag", { type: "object" })
|
||||
);
|
||||
|
||||
const a = this.store.createRecord("badge", { name: "a" });
|
||||
const b = this.store.createRecord("badge", { name: "b" });
|
||||
|
||||
assert.notStrictEqual(a.bag, b.bag, "each record gets its own object");
|
||||
a.bag.x = 1;
|
||||
assert.strictEqual(b.bag.x, undefined, "mutations do not leak");
|
||||
});
|
||||
|
||||
test("addModelField type set gives a per-instance tracked set", function (assert) {
|
||||
withPluginApi((api) => api.addModelField("badge", "seen", { type: "set" }));
|
||||
|
||||
const a = this.store.createRecord("badge", { name: "a" });
|
||||
const b = this.store.createRecord("badge", { name: "b" });
|
||||
|
||||
a.seen.add("x");
|
||||
assert.true(a.seen.has("x"));
|
||||
assert.false(b.seen.has("x"), "mutations do not leak");
|
||||
});
|
||||
|
||||
test("addModelField function defaultValue runs per instance", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "bag", { defaultValue: () => ({}) })
|
||||
);
|
||||
|
||||
const a = this.store.createRecord("badge", { name: "a" });
|
||||
const b = this.store.createRecord("badge", { name: "b" });
|
||||
|
||||
assert.notStrictEqual(a.bag, b.bag, "each record gets its own object");
|
||||
a.bag.x = 1;
|
||||
assert.strictEqual(b.bag.x, undefined, "mutations do not leak");
|
||||
});
|
||||
|
||||
test("addModelField resettable resets to the initializer when it changes", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "derivedName", {
|
||||
resettable: true,
|
||||
defaultValue() {
|
||||
return this.name;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "a" });
|
||||
assert.strictEqual(record.derivedName, "a", "derives from the instance");
|
||||
|
||||
record.derivedName = "manual";
|
||||
assert.strictEqual(
|
||||
record.derivedName,
|
||||
"manual",
|
||||
"a manual set sticks while the derived value is unchanged"
|
||||
);
|
||||
|
||||
record.name = "b";
|
||||
assert.strictEqual(
|
||||
record.derivedName,
|
||||
"b",
|
||||
"resets to the new derived value, discarding the manual set"
|
||||
);
|
||||
});
|
||||
|
||||
test("addModelSaveProperty includes the property in the save payload", async function (assert) {
|
||||
withPluginApi((api) => {
|
||||
api.addModelField("badge", "rank", { defaultValue: 0 });
|
||||
api.addModelSaveProperty("badge", "rank");
|
||||
});
|
||||
|
||||
const record = this.store.createRecord("badge", {
|
||||
id: 42,
|
||||
name: "a",
|
||||
rank: 7,
|
||||
});
|
||||
|
||||
pretender.put("/admin/badges/42", (request) => {
|
||||
const params = parsePostData(request.requestBody);
|
||||
assert.strictEqual(params.rank, "7", "merged into the payload");
|
||||
assert.step("called API");
|
||||
return response({});
|
||||
});
|
||||
|
||||
await record.save({ name: "a" });
|
||||
assert.verifySteps(["called API"]);
|
||||
});
|
||||
|
||||
test("addModelSaveProperty accepts a value function", async function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelSaveProperty("badge", "derived_name", function () {
|
||||
return this.name.toUpperCase();
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { id: 43, name: "abc" });
|
||||
|
||||
pretender.put("/admin/badges/43", (request) => {
|
||||
const params = parsePostData(request.requestBody);
|
||||
assert.strictEqual(params.derived_name, "ABC", "computed value merged");
|
||||
assert.step("called API");
|
||||
return response({});
|
||||
});
|
||||
|
||||
await record.save({ name: "abc" });
|
||||
assert.verifySteps(["called API"]);
|
||||
});
|
||||
|
||||
test("addModelCallback init fires after the create args are applied", function (assert) {
|
||||
const seen = [];
|
||||
withPluginApi((api) =>
|
||||
api.addModelCallback("badge", "init", function () {
|
||||
seen.push(this.name);
|
||||
})
|
||||
);
|
||||
|
||||
this.store.createRecord("badge", { name: "a" });
|
||||
assert.deepEqual(seen, ["a"], "runs once, with the assigned args visible");
|
||||
});
|
||||
|
||||
test("addModelCallback afterCreate fires on create", async function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelCallback("badge", "afterCreate", () => assert.step("create"))
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "a" });
|
||||
|
||||
pretender.post("/admin/badges", () => {
|
||||
assert.step("api");
|
||||
return response({});
|
||||
});
|
||||
|
||||
await record.save();
|
||||
assert.verifySteps(
|
||||
["api", "create"],
|
||||
"afterCreate fires after the request"
|
||||
);
|
||||
});
|
||||
|
||||
test("addModelCallback afterUpdate fires on update", async function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelCallback("badge", "afterUpdate", () => assert.step("update"))
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { id: 88, name: "a" });
|
||||
|
||||
pretender.put("/admin/badges/88", () => {
|
||||
assert.step("api");
|
||||
return response({});
|
||||
});
|
||||
|
||||
await record.save({ name: "b" });
|
||||
assert.verifySteps(
|
||||
["api", "update"],
|
||||
"afterUpdate fires after the request"
|
||||
);
|
||||
});
|
||||
|
||||
test("addModelCallback fires destroy callbacks around the request", async function (assert) {
|
||||
withPluginApi((api) => {
|
||||
api.addModelCallback("badge", "beforeDestroy", () =>
|
||||
assert.step("before")
|
||||
);
|
||||
api.addModelCallback("badge", "afterDestroy", () => assert.step("after"));
|
||||
});
|
||||
|
||||
const record = this.store.createRecord("badge", { id: 5, name: "a" });
|
||||
|
||||
pretender.delete("/admin/badges/5", () => {
|
||||
assert.step("api");
|
||||
return response({});
|
||||
});
|
||||
|
||||
await record.destroy();
|
||||
assert.verifySteps(["before", "api", "after"]);
|
||||
});
|
||||
|
||||
test("addModelGetter adds a getter-only derived property", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelGetter("badge", "upperName", function () {
|
||||
return this.name?.toUpperCase();
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "abc" });
|
||||
assert.strictEqual(record.upperName, "ABC");
|
||||
});
|
||||
|
||||
test("addModelSetter adds a setter-only accessor", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelSetter("badge", "shout", function (value) {
|
||||
this.name = value.toLowerCase();
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "x" });
|
||||
record.shout = "HELLO";
|
||||
assert.strictEqual(record.name, "hello");
|
||||
});
|
||||
|
||||
test("addModelAccessor adds a property with a getter and a setter", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelAccessor("badge", "upperName", {
|
||||
get() {
|
||||
return this.name?.toUpperCase();
|
||||
},
|
||||
set(value) {
|
||||
this.name = value.toLowerCase();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "abc" });
|
||||
assert.strictEqual(record.upperName, "ABC", "the getter derives a value");
|
||||
|
||||
record.upperName = "XYZ";
|
||||
assert.strictEqual(record.name, "xyz", "the setter runs");
|
||||
assert.strictEqual(record.upperName, "XYZ");
|
||||
});
|
||||
|
||||
test("addModelMethod adds an instance method", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelMethod("badge", "greet", function () {
|
||||
return `${this.name}!`;
|
||||
})
|
||||
);
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "hi" });
|
||||
assert.strictEqual(record.greet(), "hi!");
|
||||
});
|
||||
|
||||
test("addModelGetter is observable by classic observers (dependentKeyCompat)", async function (assert) {
|
||||
withPluginApi((api) => {
|
||||
api.addModelField("badge", "count", { defaultValue: 1 });
|
||||
api.addModelGetter("badge", "doubled", function () {
|
||||
return this.count * 2;
|
||||
});
|
||||
});
|
||||
|
||||
const record = this.store.createRecord("badge", { name: "a" });
|
||||
assert.strictEqual(record.doubled, 2, "derives from the tracked field");
|
||||
|
||||
// eslint-disable-next-line ember/no-observers -- verifies classic-observer interop
|
||||
addObserver(record, "doubled", () => assert.step("changed"));
|
||||
record.count = 5;
|
||||
await settled();
|
||||
|
||||
assert.verifySteps(["changed"], "a classic observer fires on change");
|
||||
assert.strictEqual(record.doubled, 10);
|
||||
});
|
||||
|
||||
test("addModelField applies to records loaded from the server", async function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("badge", "rank", { defaultValue: 5 })
|
||||
);
|
||||
|
||||
pretender.get("/badges.json", () =>
|
||||
response({
|
||||
badges: [{ id: 1, name: "a", badge_type_id: 1 }],
|
||||
badge_types: [{ id: 1, name: "Gold" }],
|
||||
badge_groupings: [],
|
||||
})
|
||||
);
|
||||
|
||||
const badges = await Badge.findAll();
|
||||
assert.strictEqual(badges.length, 1, "loads the badge");
|
||||
assert.strictEqual(
|
||||
badges[0].rank,
|
||||
5,
|
||||
"reads the registered field off a cached record"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { setupTest } from "ember-qunit";
|
||||
import { module, test } from "qunit";
|
||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
import TopicDetails from "discourse/models/topic-details";
|
||||
import User from "discourse/models/user";
|
||||
|
||||
module("Unit | Model | topic-details", function (hooks) {
|
||||
@@ -31,4 +33,35 @@ module("Unit | Model | topic-details", function (hooks) {
|
||||
);
|
||||
assert.containsInstance(details.allowed_users, User);
|
||||
});
|
||||
|
||||
// The base constructor seeds registered fields and fires `init` callbacks
|
||||
// before this subclass's own (private) state exists.
|
||||
test("a plugin init callback can read id and __resource during construction", function (assert) {
|
||||
let seen;
|
||||
withPluginApi((api) =>
|
||||
api.addModelCallback("topic-details", "init", function () {
|
||||
seen = { id: this.id, resource: this.__resource };
|
||||
})
|
||||
);
|
||||
|
||||
const details = TopicDetails.create({ id: 123 });
|
||||
|
||||
assert.deepEqual(
|
||||
seen,
|
||||
{ id: null, resource: undefined },
|
||||
"reports no id while the base constructor runs"
|
||||
);
|
||||
assert.strictEqual(details.id, "123", "resolves the id afterwards");
|
||||
});
|
||||
|
||||
test("a plugin-registered field seeds its default", function (assert) {
|
||||
withPluginApi((api) =>
|
||||
api.addModelField("topic-details", "pluginFlag", { defaultValue: 7 })
|
||||
);
|
||||
|
||||
const details = TopicDetails.create({ id: 55 });
|
||||
|
||||
assert.strictEqual(details.pluginFlag, 7, "applies the registered default");
|
||||
assert.strictEqual(details.id, "55");
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+133
@@ -306,6 +306,21 @@ importers:
|
||||
'@lezer/lr':
|
||||
specifier: ^1.4.8
|
||||
version: 1.4.10
|
||||
'@warp-drive/core':
|
||||
specifier: 5.9.0-alpha.15
|
||||
version: 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/ember':
|
||||
specifier: 5.9.0-alpha.15
|
||||
version: 5.9.0-alpha.15(@babel/core@7.29.7)(@ember/test-waiters@4.1.1(@babel/core@7.29.7)(@glint/template@1.7.8))(@glint/template@1.7.8)
|
||||
'@warp-drive/json-api':
|
||||
specifier: 5.9.0-alpha.15
|
||||
version: 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))
|
||||
'@warp-drive/legacy':
|
||||
specifier: 5.9.0-alpha.15
|
||||
version: 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))(@warp-drive/utilities@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)))
|
||||
'@warp-drive/utilities':
|
||||
specifier: 5.9.0-alpha.15
|
||||
version: 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))
|
||||
ace-builds:
|
||||
specifier: ^1.44.0
|
||||
version: 1.44.0
|
||||
@@ -3233,6 +3248,37 @@ packages:
|
||||
'@vscode/l10n@0.0.18':
|
||||
resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
|
||||
|
||||
'@warp-drive/build-config@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-mZgJSAfowe8IkjWe+7RmZtD17ZM9XdUKPWvYeLY4xcNW50AirD9f3kNcaMQo81h7al7b3tSOA32RRBABcI65Wg==}
|
||||
|
||||
'@warp-drive/core@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-WckK31nhuv5n/KDHusPmVMaj6FYR/Us6Zu2ppU7rRO7H6YfZMC+9Lp2q+in0vD9WhMtcOt85dTxMpzfElwv67Q==}
|
||||
|
||||
'@warp-drive/ember@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-VcQDendVOwDwcyxz5CCGaCUqs3/fhYJ6meGBOLhbnxyQ4O26pdU1vb443TKL0B6WVd6DaoPTV6MG3lfxezZA/w==}
|
||||
peerDependencies:
|
||||
'@ember/test-waiters': 4.1.1
|
||||
ember-provide-consume-context: ^0.8.0
|
||||
peerDependenciesMeta:
|
||||
ember-provide-consume-context:
|
||||
optional: true
|
||||
|
||||
'@warp-drive/json-api@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-LgPHAd3QavtnNMBJzRghLX1jHci3sb4mbl01G9/29fxgpCS/baFFg7ANx3s14m2H3Cjg5+bktEdEAKXsqib92A==}
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.9.0-alpha.15
|
||||
|
||||
'@warp-drive/legacy@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-69Y/04GC66zpZn6bxjKwcHRkJBMHaZf4xFS4KoKT6MIRKO+JW53DO2ixH3tZP7Zksrbtyaka8vq0HXPbOzrGWg==}
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.9.0-alpha.15
|
||||
'@warp-drive/utilities': 5.9.0-alpha.15
|
||||
|
||||
'@warp-drive/utilities@5.9.0-alpha.15':
|
||||
resolution: {integrity: sha512-de/7szw8Qaz6rVv4mxQ3L00iKMhGHlRgTdiOYhq74PEGFrqSdv91sdVB+WOVjVhSdCrvs06FoUhkSZQ0Us/jxQ==}
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.9.0-alpha.15
|
||||
|
||||
'@xmldom/xmldom@0.9.10':
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
@@ -3906,6 +3952,10 @@ packages:
|
||||
resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
code-error-fragment@0.0.230:
|
||||
resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
@@ -5118,6 +5168,10 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
fuse.js@7.1.0:
|
||||
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
gauge@5.0.2:
|
||||
resolution: {integrity: sha512-pMaFftXPtiGIHCJHdcUUx9Rby/rFT/Kkt3fIIGCs+9PMDIljSyRiqraTlxNtBReJRDfUefpa263RQ3vnp5G/LQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -5251,6 +5305,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
grapheme-splitter@1.0.4:
|
||||
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==}
|
||||
|
||||
growly@1.3.0:
|
||||
resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
|
||||
|
||||
@@ -5837,6 +5894,10 @@ packages:
|
||||
json-stringify-nice@1.1.4:
|
||||
resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==}
|
||||
|
||||
json-to-ast@2.1.0:
|
||||
resolution: {integrity: sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
json5@1.0.2:
|
||||
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
||||
hasBin: true
|
||||
@@ -11289,6 +11350,67 @@ snapshots:
|
||||
|
||||
'@vscode/l10n@0.0.18': {}
|
||||
|
||||
'@warp-drive/build-config@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)':
|
||||
dependencies:
|
||||
'@embroider/addon-shim': 1.10.3
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
babel-import-util: 2.1.1
|
||||
babel-plugin-debug-macros: 2.0.0(@babel/core@7.29.7)
|
||||
semver: 7.8.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)':
|
||||
dependencies:
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/build-config': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@warp-drive/ember@5.9.0-alpha.15(@babel/core@7.29.7)(@ember/test-waiters@4.1.1(@babel/core@7.29.7)(@glint/template@1.7.8))(@glint/template@1.7.8)':
|
||||
dependencies:
|
||||
'@ember/test-waiters': 4.1.1(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/core': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@warp-drive/json-api@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))':
|
||||
dependencies:
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/core': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
fuse.js: 7.1.0
|
||||
json-to-ast: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@warp-drive/legacy@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))(@warp-drive/utilities@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)))':
|
||||
dependencies:
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/core': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/utilities': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@warp-drive/utilities@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)(@warp-drive/core@5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8))':
|
||||
dependencies:
|
||||
'@embroider/macros': 1.20.6(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
'@warp-drive/core': 5.9.0-alpha.15(@babel/core@7.29.7)(@glint/template@1.7.8)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
a11y-dialog@8.1.5:
|
||||
@@ -12150,6 +12272,8 @@ snapshots:
|
||||
|
||||
cmd-shim@6.0.3: {}
|
||||
|
||||
code-error-fragment@0.0.230: {}
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
@@ -13834,6 +13958,8 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuse.js@7.1.0: {}
|
||||
|
||||
gauge@5.0.2:
|
||||
dependencies:
|
||||
aproba: 2.1.0
|
||||
@@ -13998,6 +14124,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
grapheme-splitter@1.0.4: {}
|
||||
|
||||
growly@1.3.0: {}
|
||||
|
||||
handlebars@4.7.9:
|
||||
@@ -14590,6 +14718,11 @@ snapshots:
|
||||
|
||||
json-stringify-nice@1.1.4: {}
|
||||
|
||||
json-to-ast@2.1.0:
|
||||
dependencies:
|
||||
code-error-fragment: 0.0.230
|
||||
grapheme-splitter: 1.0.4
|
||||
|
||||
json5@1.0.2:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
Reference in New Issue
Block a user