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:
Jarek Radosz
2026-09-03 08:57:28 +02:00
committed by GitHub
parent 5e191ccc33
commit a6e133ceab
53 changed files with 2378 additions and 503 deletions
@@ -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`.
+1
View File
@@ -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}}
/>
+1
View File
@@ -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) } };
}
+180
View File
@@ -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),
};
}
+255
View File
@@ -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);
}
}
+20 -21
View File
@@ -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));
}
+28 -15
View File
@@ -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);
}
}
+8 -6
View File
@@ -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);
}
}
+24 -113
View File
@@ -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);
+65 -77
View File
@@ -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);
+8 -4
View File
@@ -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);
+7 -16
View File
@@ -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);
+9 -9
View File
@@ -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);
+118 -65
View File
@@ -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);
+90 -145
View File
@@ -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,
}) {}
+7
View File
@@ -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" });
},
});
+5
View File
@@ -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",
+2
View File
@@ -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");
});
});
+133
View File
@@ -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