@riducms/admin 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +11 -9
  2. package/src/app.css +63 -4
  3. package/src/app.svelte +12 -14
  4. package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
  5. package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
  6. package/src/components/ui/select/select-trigger.svelte +1 -1
  7. package/src/core/forms/embedded-fields.ts +132 -0
  8. package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
  9. package/src/core/forms/field-editor-authoring.ts +47 -0
  10. package/src/core/forms/field-editor-binding.ts +228 -0
  11. package/src/core/forms/field-issue-focus.ts +42 -0
  12. package/src/core/forms/field-occurrence.ts +122 -0
  13. package/src/core/forms/form-controller.svelte.ts +475 -29
  14. package/src/core/forms/form-issue-correlation.ts +159 -0
  15. package/src/core/forms/form-schema.ts +211 -17
  16. package/src/core/forms/form-validation.ts +183 -17
  17. package/src/core/forms/live-validation.svelte.ts +317 -0
  18. package/src/core/forms/plugin-field-authoring.ts +160 -0
  19. package/src/core/forms/plugin-field-binding.ts +258 -0
  20. package/src/core/forms/plugin-field-write.ts +57 -0
  21. package/src/core/i18n/admin-i18n.svelte.ts +8 -1
  22. package/src/core/i18n/localized-schema.ts +33 -10
  23. package/src/core/plugins/field-registry.ts +29 -49
  24. package/src/core/plugins/row-label-registry.ts +25 -5
  25. package/src/core/runtime/admin-runtime.svelte.ts +25 -13
  26. package/src/features/auth/account-route.svelte +10 -5
  27. package/src/features/auth/account-security-route.svelte +1 -1
  28. package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
  29. package/src/features/collections/collection-list-controller.svelte.ts +5 -5
  30. package/src/features/collections/collection-list-filter-popover.svelte +6 -6
  31. package/src/features/collections/collection-list-route.svelte +13 -14
  32. package/src/features/collections/list-workspace.ts +22 -8
  33. package/src/features/documents/document-controller.svelte.ts +8 -2
  34. package/src/features/documents/document-route.svelte +36 -43
  35. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
  36. package/src/features/reference-browser/reference-browser.svelte +215 -189
  37. package/src/features/uploads/upload-metadata-fields.svelte +6 -6
  38. package/src/features/versions/version-history-route.svelte +1 -1
  39. package/src/fields/embedded-schema-draft-editor.svelte +74 -0
  40. package/src/fields/embedded-schema-fields.svelte +44 -0
  41. package/src/fields/field-clipboard.ts +76 -16
  42. package/src/fields/field-collapsible.svelte +49 -0
  43. package/src/fields/field-layout.svelte +2 -18
  44. package/src/fields/field-messages.svelte +13 -18
  45. package/src/fields/field-renderer.svelte +53 -14
  46. package/src/fields/join/join-field.svelte +8 -1
  47. package/src/fields/live-validation-feedback.svelte +21 -0
  48. package/src/fields/local-field-editor.svelte +52 -0
  49. package/src/fields/nested/nested-field.svelte +159 -91
  50. package/src/fields/nested/scoped-field.ts +7 -9
  51. package/src/fields/plugin-field.svelte +156 -0
  52. package/src/fields/point/point-field.svelte +12 -2
  53. package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
  54. package/src/fields/primitive-list/primitive-number-input.ts +6 -0
  55. package/src/fields/radio/radio-field.svelte +13 -9
  56. package/src/fields/relationship/relationship-field.svelte +36 -12
  57. package/src/fields/relationship/relationship-query.ts +4 -2
  58. package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
  59. package/src/fields/scalar/date-control-value.ts +5 -5
  60. package/src/fields/scalar/date-value.ts +5 -5
  61. package/src/fields/scalar/scalar-field.svelte +47 -41
  62. package/src/fields/select/select-field.svelte +24 -24
  63. package/src/fields/select/select-value.ts +3 -3
  64. package/src/fields/text/slug-field.svelte +24 -8
  65. package/src/fields/text/text-field.svelte +8 -6
  66. package/src/index.ts +4 -12
@@ -0,0 +1,160 @@
1
+ import type {
2
+ EmbeddedSchemaDraft,
3
+ FieldAuthoringHost,
4
+ FieldReferenceBrowserProps,
5
+ } from "@riducms/plugin";
6
+ import type { PluginFieldBinding } from "@admin/core/forms/plugin-field-binding";
7
+ import { cloneFormValue } from "@admin/core/forms/form-schema";
8
+
9
+ /** Every retained callable checks its lease, including after asynchronous host operations. */
10
+ export function guardPluginAuthoring(
11
+ host: FieldAuthoringHost,
12
+ binding: PluginFieldBinding,
13
+ registerDraft?: (draft: EmbeddedSchemaDraft, raw: EmbeddedSchemaDraft) => void
14
+ ): FieldAuthoringHost {
15
+ return {
16
+ get collections() {
17
+ binding.assertActive();
18
+ return cloneFormValue(host.collections) as typeof host.collections;
19
+ },
20
+ get documentRevision() {
21
+ binding.assertActive();
22
+ return host.documentRevision;
23
+ },
24
+ get locale() {
25
+ binding.assertActive();
26
+ return host.locale;
27
+ },
28
+ referenceBrowser: (anchor, props) => {
29
+ binding.assertActive();
30
+ const overrides = {
31
+ get readOnly() {
32
+ return binding.readOnly || props.readOnly;
33
+ },
34
+ onCommit: async (ids: string[]) => {
35
+ binding.assertEditable();
36
+ const result = await props.onCommit([...ids]);
37
+ binding.assertEditable();
38
+ return result;
39
+ },
40
+ onClose: () => {
41
+ binding.assertActive();
42
+ props.onClose();
43
+ },
44
+ };
45
+ // Delegate every read, including spread-props proxies whose backing object is replaced.
46
+ const guarded = new Proxy({} as FieldReferenceBrowserProps, {
47
+ get: (_target, key) =>
48
+ Reflect.has(overrides, key) ? Reflect.get(overrides, key) : Reflect.get(props, key),
49
+ set: (_target, key, value) => Reflect.set(props, key, value),
50
+ has: (_target, key) => Reflect.has(overrides, key) || Reflect.has(props, key),
51
+ ownKeys: () => [...new Set([...Reflect.ownKeys(props), ...Reflect.ownKeys(overrides)])],
52
+ getOwnPropertyDescriptor: (_target, key) => {
53
+ if (!Reflect.has(overrides, key) && !Reflect.has(props, key)) return undefined;
54
+ const setter = Reflect.has(overrides, key)
55
+ ? undefined
56
+ : Reflect.getOwnPropertyDescriptor(props, key)?.set;
57
+ return {
58
+ configurable: true,
59
+ enumerable: true,
60
+ get: () => Reflect.get(guarded, key),
61
+ ...(setter === undefined
62
+ ? {}
63
+ : {
64
+ set: (value: unknown) => {
65
+ Reflect.set(props, key, value);
66
+ },
67
+ }),
68
+ };
69
+ },
70
+ });
71
+ return host.referenceBrowser(anchor, guarded);
72
+ },
73
+ async findDocument(...args) {
74
+ binding.assertActive();
75
+ const result = await host.findDocument(...args);
76
+ binding.assertActive();
77
+ return cloneFormValue(result) as typeof result;
78
+ },
79
+ ...(host.requestPlugin === undefined
80
+ ? {}
81
+ : {
82
+ async requestPlugin<Result>(
83
+ path: string,
84
+ body: unknown,
85
+ signal?: AbortSignal
86
+ ): Promise<Result> {
87
+ binding.assertEditable();
88
+ const detached = cloneFormValue(body);
89
+ binding.assertEditable();
90
+ const result = await host.requestPlugin!<Result>(path, detached, signal);
91
+ binding.assertEditable();
92
+ return cloneFormValue(result) as Result;
93
+ },
94
+ }),
95
+ ...(host.schemaForm === undefined ? {} : { schemaForm: host.schemaForm }),
96
+ ...(host.schemaIssues === undefined
97
+ ? {}
98
+ : {
99
+ schemaIssues: (scope) => {
100
+ binding.assertActive();
101
+ return host.schemaIssues!(scope).map((issue) => ({ ...issue }));
102
+ },
103
+ }),
104
+ ...(host.copySchemaPayload === undefined
105
+ ? {}
106
+ : {
107
+ copySchemaPayload: (scope, payload) => {
108
+ binding.assertActive();
109
+ const result = host.copySchemaPayload!(scope, payload);
110
+ binding.assertActive();
111
+ return result;
112
+ },
113
+ }),
114
+ ...(host.beginSchemaDraft === undefined
115
+ ? {}
116
+ : {
117
+ beginSchemaDraft: (scope) => {
118
+ binding.assertEditable();
119
+ const raw = host.beginSchemaDraft!({ ...scope });
120
+ const discard = raw.discard;
121
+ const stop = binding.onDestroy(() => raw.discard());
122
+ // Drawers dispose the raw session; every disposal must release owner cleanup.
123
+ raw.discard = () => {
124
+ stop();
125
+ discard();
126
+ };
127
+ const assertDraft = () => {
128
+ binding.assertEditable();
129
+ if (raw.stale) throw new Error("This embedded schema draft is stale.");
130
+ };
131
+ const draft: EmbeddedSchemaDraft = {
132
+ id: raw.id,
133
+ identity: raw.identity,
134
+ get stale() {
135
+ return binding.stale || raw.stale;
136
+ },
137
+ get dirty() {
138
+ return !binding.stale && raw.dirty;
139
+ },
140
+ get issues() {
141
+ binding.assertActive();
142
+ return raw.issues.map((issue) => ({ ...issue }));
143
+ },
144
+ payload: () => {
145
+ assertDraft();
146
+ return cloneFormValue(raw.payload()) as Record<string, unknown>;
147
+ },
148
+ validate: () => {
149
+ assertDraft();
150
+ return raw.validate();
151
+ },
152
+ discard: raw.discard,
153
+ };
154
+ registerDraft?.(draft, raw);
155
+ return draft;
156
+ },
157
+ }),
158
+ ...(host.schemaDraftEditor === undefined ? {} : { schemaDraftEditor: host.schemaDraftEditor }),
159
+ };
160
+ }
@@ -0,0 +1,258 @@
1
+ import { cloneSchemaField } from "@riducms/protocol";
2
+ import type {
3
+ PluginFieldBinding as Binding,
4
+ PluginForm,
5
+ RegisteredPluginField,
6
+ } from "@riducms/plugin";
7
+ import type { SchemaField } from "@riducms/protocol";
8
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
9
+ import { captureFieldOccurrence } from "@admin/core/forms/field-occurrence";
10
+ import { writePluginField } from "@admin/core/forms/plugin-field-write";
11
+ import { cloneFormValue } from "@admin/core/forms/form-schema";
12
+ import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
13
+ import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
14
+
15
+ /** The form owns values. This lease owns registration, occurrence tracking and child capabilities. */
16
+ export class PluginFieldBinding implements Binding<unknown> {
17
+ #stale = false;
18
+ #path: string;
19
+ #resolvePath: () => string | undefined;
20
+ #ancestors: ReturnType<typeof captureFieldOccurrence>["ancestors"];
21
+ #epoch: number;
22
+ #version: unknown;
23
+ #schemaID: string;
24
+ #resource: string;
25
+ #locale: string | undefined;
26
+ #stopPath: () => void;
27
+ #stopObserve: () => void;
28
+ #stopLifetime: () => void;
29
+ #cleanup = new Set<() => void>();
30
+ readonly form: PluginForm;
31
+ constructor(
32
+ private controller: FormController,
33
+ private getSchema: () => SchemaField,
34
+ private registration: Pick<RegisteredPluginField, "decodeValue"> &
35
+ Partial<Pick<RegisteredPluginField, "decodeInput" | "decodeFormValue">>,
36
+ private schemaVersion: () => unknown = () => controller.editorEpoch,
37
+ private owner?: PluginFieldBinding
38
+ ) {
39
+ const schema = getSchema();
40
+ this.#path = schema.path;
41
+ const occurrence = captureFieldOccurrence(controller, schema.path);
42
+ this.#resolvePath = occurrence.resolve;
43
+ this.#ancestors = occurrence.ancestors;
44
+ this.#epoch = controller.editorEpoch;
45
+ this.#schemaID = schema.id;
46
+ this.#version = schemaVersion();
47
+ this.#resource = JSON.stringify(controller.resource);
48
+ this.#locale = controller.contentLocale;
49
+ this.#stopPath = controller.register(this.#path);
50
+ this.#stopObserve = controller.observe(this.#path.split(".")[0]!, () => this.#resolve());
51
+ this.#stopLifetime = controller.registerEditorLifetime(this.destroy);
52
+ const binding = this;
53
+ this.form = {
54
+ get contentLocale() {
55
+ binding.assertActive();
56
+ return controller.contentLocale;
57
+ },
58
+ get resource() {
59
+ binding.assertActive();
60
+ return cloneFormValue(controller.resource) as typeof controller.resource;
61
+ },
62
+ get: (path) => {
63
+ this.assertActive();
64
+ return cloneFormValue(controller.get(path));
65
+ },
66
+ issuesFor: (path) => {
67
+ this.assertActive();
68
+ return controller.issuesFor(path).map((issue) => ({ ...issue }));
69
+ },
70
+ snapshot: () => {
71
+ this.assertActive();
72
+ return controller.snapshot();
73
+ },
74
+ bind: (path) => {
75
+ this.assertActive();
76
+ const captured = captureFieldOccurrence(controller, path);
77
+ const child = new PluginFieldBinding(
78
+ controller,
79
+ () => captured.schema,
80
+ { decodeValue: (value) => value },
81
+ schemaVersion,
82
+ this
83
+ );
84
+ this.onDestroy(child.destroy);
85
+ return child;
86
+ },
87
+ };
88
+ }
89
+ get stale() {
90
+ this.#resolve();
91
+ return this.#stale;
92
+ }
93
+ get schema() {
94
+ this.assertActive();
95
+ const schema = cloneSchemaField(this.getSchema());
96
+ return {
97
+ ...schema,
98
+ path: this.#path,
99
+ admin: { ...schema.admin, readOnly: this.#isReadOnly() },
100
+ };
101
+ }
102
+ get rawValue() {
103
+ this.assertActive();
104
+ return cloneFormValue(this.controller.get(this.#path));
105
+ }
106
+ get value() {
107
+ const raw = this.rawValue;
108
+ const value =
109
+ raw === null || raw === undefined
110
+ ? raw
111
+ : (this.registration.decodeFormValue ?? this.registration.decodeValue)(raw);
112
+ this.assertActive();
113
+ return copyValue(value);
114
+ }
115
+ get issues() {
116
+ this.assertActive();
117
+ return this.controller.issuesFor(this.#path).map((issue) => ({ ...issue }));
118
+ }
119
+ get liveValidation() {
120
+ if (this.stale) return { status: "idle" as const, retry: () => {} };
121
+ const feedback = this.controller.liveValidation.forField(this.#path);
122
+ return {
123
+ status: feedback.status,
124
+ retry: () => {
125
+ this.assertActive();
126
+ if (!this.readOnly) this.controller.liveValidation.flush(this.#path);
127
+ },
128
+ };
129
+ }
130
+
131
+ get readOnly() {
132
+ return this.controller.editingBlocked || this.#isReadOnly();
133
+ }
134
+ #isReadOnly() {
135
+ if (this.stale || (this.owner !== undefined && this.owner.#isReadOnly())) return true;
136
+ if (
137
+ this.#ancestors.some(({ schema, resolve }) => {
138
+ const path = resolve();
139
+ return (
140
+ path === undefined ||
141
+ schema.admin.readOnly === true ||
142
+ schema.admin.hidden === true ||
143
+ !this.controller.canRead(path, fieldAccessPath(schema)) ||
144
+ !this.controller.canWrite(path, fieldAccessPath(schema)) ||
145
+ (schema.admin.condition !== undefined &&
146
+ !evaluateFieldCondition(schema.admin.condition, path, (other) =>
147
+ this.controller.get(other)
148
+ ))
149
+ );
150
+ })
151
+ )
152
+ return true;
153
+ const field = this.getSchema();
154
+ return (
155
+ field.admin.readOnly === true ||
156
+ field.admin.hidden === true ||
157
+ !this.controller.canRead(this.#path, fieldAccessPath(field)) ||
158
+ !this.controller.canWrite(this.#path, fieldAccessPath(field)) ||
159
+ (field.admin.condition !== undefined &&
160
+ !evaluateFieldCondition(field.admin.condition, this.#path, (path) =>
161
+ this.controller.get(path)
162
+ ))
163
+ );
164
+ }
165
+ set = (value: unknown) => {
166
+ this.assertEditable();
167
+ if (value === undefined) throw new Error("Use null to clear a field value.");
168
+ const supplied = copyValue(value);
169
+ const decoded =
170
+ supplied === null
171
+ ? null
172
+ : (this.registration.decodeInput ?? this.registration.decodeValue)(supplied);
173
+ // Decoders and getters are application code and may revoke the binding synchronously.
174
+ const detached = copyValue(decoded);
175
+ this.assertEditable();
176
+ const schema = { ...this.getSchema(), path: this.#path };
177
+ writePluginField(this.controller, schema, detached);
178
+ };
179
+ assertActive = () => {
180
+ if (this.stale)
181
+ throw new Error(
182
+ "This plugin field binding is stale. Use the current mounted field occurrence."
183
+ );
184
+ };
185
+ assertEditable = () => {
186
+ this.assertActive();
187
+ if (this.readOnly) throw new Error("This plugin field is read-only.");
188
+ };
189
+ onDestroy = (cleanup: () => void) => {
190
+ this.assertActive();
191
+ this.#cleanup.add(cleanup);
192
+ return () => {
193
+ this.#cleanup.delete(cleanup);
194
+ };
195
+ };
196
+ destroy = () => {
197
+ if (this.#stale) return;
198
+ this.#stale = true;
199
+ this.#stopPath();
200
+ this.#stopObserve();
201
+ this.#stopLifetime();
202
+ for (const cleanup of [...this.#cleanup]) cleanup();
203
+ this.#cleanup.clear();
204
+ };
205
+ #resolve() {
206
+ if (this.#stale) return;
207
+ if (
208
+ !this.controller.editorScopeActive ||
209
+ this.controller.editorEpoch !== this.#epoch ||
210
+ this.schemaVersion() !== this.#version ||
211
+ this.getSchema().id !== this.#schemaID ||
212
+ this.controller.contentLocale !== this.#locale ||
213
+ JSON.stringify(this.controller.resource) !== this.#resource
214
+ ) {
215
+ this.destroy();
216
+ return;
217
+ }
218
+ const path = this.#resolvePath();
219
+ if (path === undefined) {
220
+ this.destroy();
221
+ return;
222
+ }
223
+ if (path !== this.#path) {
224
+ this.#stopPath();
225
+ this.#path = path;
226
+ this.#stopPath = this.controller.register(path);
227
+ }
228
+ }
229
+ }
230
+
231
+ /** Reject non-JSON mutable objects before they can enter the form (including decoder outputs). */
232
+ function copyValue(value: unknown, seen = new Set<object>(), depth = 0): unknown {
233
+ if (depth > 100) throw new Error("Plugin value is too deeply nested.");
234
+ if (
235
+ value === null ||
236
+ value === undefined ||
237
+ typeof value === "string" ||
238
+ typeof value === "boolean"
239
+ )
240
+ return value;
241
+ if (typeof value === "number" && Number.isFinite(value)) return value;
242
+ if (
243
+ typeof value !== "object" ||
244
+ seen.has(value) ||
245
+ (!Array.isArray(value) &&
246
+ Object.getPrototypeOf(value) !== Object.prototype &&
247
+ Object.getPrototypeOf(value) !== null)
248
+ )
249
+ throw new Error("Plugin values must be finite, acyclic JSON data.");
250
+ seen.add(value);
251
+ const result = Array.isArray(value)
252
+ ? value.map((item) => copyValue(item, seen, depth + 1))
253
+ : Object.fromEntries(
254
+ Object.entries(value).map(([key, item]) => [key, copyValue(item, seen, depth + 1)])
255
+ );
256
+ seen.delete(value);
257
+ return result;
258
+ }
@@ -0,0 +1,57 @@
1
+ import type { SchemaField } from "@riducms/protocol";
2
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
3
+ import { indexFieldValues } from "@admin/core/forms/form-issue-correlation";
4
+ import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
5
+ import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
6
+
7
+ /** Structural plugin writes use the same schema identity index as server issue correlation. */
8
+ export function writePluginField(form: FormController, field: SchemaField, value: unknown) {
9
+ const before = indexFieldValues([field], { [field.name]: form.get(field.path) });
10
+ const after = indexFieldValues([field], { [field.name]: value });
11
+ const current = new Map(after.map((location) => [location.token, location]));
12
+ for (const source of before) {
13
+ const schema = source.schema,
14
+ target = current.get(source.token);
15
+ if (!schema || !target || JSON.stringify(source.value) === JSON.stringify(target.value))
16
+ continue;
17
+ if (
18
+ schema.admin.readOnly ||
19
+ schema.admin.hidden ||
20
+ !form.canRead(source.path, fieldAccessPath(schema)) ||
21
+ !form.canWrite(source.path, fieldAccessPath(schema)) ||
22
+ (schema.admin.condition !== undefined &&
23
+ !evaluateFieldCondition(schema.admin.condition, source.path, (path) => form.get(path)))
24
+ )
25
+ throw new Error(`Field ${source.path} is read-only.`);
26
+ }
27
+ if (form.access !== undefined) {
28
+ const previous = new Map(before.map((location) => [location.path, location]));
29
+ const fields: typeof form.access.fields = {};
30
+ for (const [path, access] of Object.entries(form.access.fields)) {
31
+ if (path !== field.path && !path.startsWith(`${field.path}.`)) {
32
+ fields[path] = access;
33
+ continue;
34
+ }
35
+ let prefix = path;
36
+ while (!previous.has(prefix) && prefix.includes("."))
37
+ prefix = prefix.slice(0, prefix.lastIndexOf("."));
38
+ const source = previous.get(prefix);
39
+ if (!source) {
40
+ fields[path] = access;
41
+ continue;
42
+ }
43
+ const target = current.get(source.token);
44
+ if (!target) continue;
45
+ const suffix = path.slice(source.path.length);
46
+ if (
47
+ /\.\d+(?:\.|$)/.test(suffix) &&
48
+ JSON.stringify(source.value) !== JSON.stringify(target.value)
49
+ )
50
+ continue;
51
+ fields[target.path + suffix] = access;
52
+ }
53
+ form.access = { ...form.access, fields };
54
+ }
55
+ if (field.plugin?.embeddedTrees !== undefined) form.setEmbedded(field, value, false);
56
+ else form.set(field.path, value);
57
+ }
@@ -45,7 +45,8 @@ export class AdminI18nController implements AdminI18n {
45
45
  private readonly client: AdminClient,
46
46
  private readonly session: () => AuthSession<AdminDocument> | undefined,
47
47
  catalogs: readonly TranslationLanguage[] = [en],
48
- messages: Readonly<Record<string, PluginMessageCatalog>> = {}
48
+ messages: Readonly<Record<string, PluginMessageCatalog>> = {},
49
+ private readonly applicationMessages?: PluginMessageCatalog
49
50
  ) {
50
51
  this.#catalogs = catalogs.length === 0 ? [en] : [...catalogs];
51
52
  this.#catalogByCode = new Map(this.#catalogs.map((catalog) => [catalog.code, catalog]));
@@ -100,6 +101,9 @@ export class AdminI18nController implements AdminI18n {
100
101
  fallbackLanguage: this.#fallbackLanguage,
101
102
  ...(this.#timeZone === undefined ? {} : { timeZone: this.#timeZone }),
102
103
  pluginMessages: this.#messages,
104
+ ...(this.applicationMessages === undefined
105
+ ? {}
106
+ : { applicationMessages: this.applicationMessages }),
103
107
  }));
104
108
  }
105
109
 
@@ -167,6 +171,9 @@ export class AdminI18nController implements AdminI18n {
167
171
  fallbackLanguage,
168
172
  ...(timeZone === undefined ? {} : { timeZone }),
169
173
  pluginMessages: this.#messages,
174
+ ...(this.applicationMessages === undefined
175
+ ? {}
176
+ : { applicationMessages: this.applicationMessages }),
170
177
  });
171
178
 
172
179
  // Commit only after every catalog, direction, locale, and timezone validates.
@@ -1,3 +1,4 @@
1
+ import { mapBlockTypes } from "@riducms/protocol";
1
2
  import type { AdminI18n } from "@riducms/plugin";
2
3
  import type { SchemaCollection, SchemaField } from "@riducms/protocol";
3
4
 
@@ -35,9 +36,9 @@ export function localizeSchemaField(field: SchemaField, i18n: AdminI18n): Schema
35
36
  : {
36
37
  select: {
37
38
  ...field.select,
38
- choices: field.select.choices.map((choice) => ({
39
- ...choice,
40
- label: i18n.text(choice.label, choice.labelTranslations),
39
+ options: field.select.options.map((option) => ({
40
+ ...option,
41
+ label: i18n.text(option.label, option.labelTranslations),
41
42
  })),
42
43
  },
43
44
  }),
@@ -64,18 +65,40 @@ export function localizeSchemaField(field: SchemaField, i18n: AdminI18n): Schema
64
65
  }),
65
66
  },
66
67
  }),
67
- ...(field.blocks === undefined
68
+ ...(field.plugin?.embeddedTrees === undefined
68
69
  ? {}
69
70
  : {
70
- blocks: {
71
- ...field.blocks,
72
- types: field.blocks.types.map((block) => ({
73
- ...block,
74
- label: i18n.text(block.label, block.labelTranslations),
75
- fields: block.fields.map((child) => localizeSchemaField(child, i18n)),
71
+ plugin: {
72
+ ...field.plugin,
73
+ embeddedTrees: field.plugin.embeddedTrees.map((tree) => ({
74
+ ...tree,
75
+ cases: tree.cases.map((branch) =>
76
+ mapBlockTypes(branch, (block) => ({
77
+ ...block,
78
+ labels: {
79
+ ...block.labels,
80
+ singular: i18n.text(block.labels.singular, block.labels.singularTranslations),
81
+ plural: i18n.text(block.labels.plural, block.labels.pluralTranslations),
82
+ },
83
+ fields: block.fields.map((child) => localizeSchemaField(child, i18n)),
84
+ }))
85
+ ),
76
86
  })),
77
87
  },
78
88
  }),
89
+ ...(field.blocks === undefined
90
+ ? {}
91
+ : {
92
+ blocks: mapBlockTypes(field.blocks, (block) => ({
93
+ ...block,
94
+ labels: {
95
+ ...block.labels,
96
+ singular: i18n.text(block.labels.singular, block.labels.singularTranslations),
97
+ plural: i18n.text(block.labels.plural, block.labels.pluralTranslations),
98
+ },
99
+ fields: block.fields.map((child) => localizeSchemaField(child, i18n)),
100
+ })),
101
+ }),
79
102
  };
80
103
  }
81
104
 
@@ -1,60 +1,40 @@
1
- import type { FieldPlugin } from "@riducms/plugin";
2
- import type { SchemaField } from "@riducms/protocol";
1
+ import { resolvePluginFields, type AdminPlugin, type ResolvedPluginField } from "@riducms/plugin";
2
+ import type { FieldType, SchemaField } from "@riducms/protocol";
3
3
 
4
+ interface FieldRenderer {
5
+ type: FieldType;
6
+ extension?: ResolvedPluginField;
7
+ }
4
8
  export class FieldRegistry {
5
- #plugins = new Map<string, FieldPlugin>();
6
-
7
- register(plugin: FieldPlugin) {
8
- const identity = pluginIdentity(plugin);
9
+ #plugins = new Map<string, FieldRenderer>();
10
+ register(plugin: ResolvedPluginField) {
11
+ const identity =
12
+ plugin.componentKey === undefined
13
+ ? `plugin:${plugin.key}`
14
+ : `${plugin.registration.type}:${plugin.owner}:${plugin.key}`;
9
15
  if (this.#plugins.has(identity))
10
- throw new Error(`Field plugin ${identity} is already registered`);
11
- this.#plugins.set(identity, plugin);
16
+ throw new Error(`Field renderer ${identity} is already registered.`);
17
+ this.#plugins.set(identity, { type: plugin.registration.type, extension: plugin });
12
18
  }
13
-
14
- resolve(field: SchemaField) {
19
+ resolve(field: SchemaField): FieldRenderer {
20
+ if (field.admin.editor !== undefined)
21
+ throw new Error(
22
+ `No local editor ${field.admin.editor.reference} is registered for ${field.path}.`
23
+ );
15
24
  const component = field.admin.component;
25
+ if (component === undefined && field.type !== "plugin") return { type: field.type };
16
26
  const identity =
17
- component !== undefined
18
- ? `${field.type}:${component.plugin}:${component.component}`
19
- : field.plugin === undefined
20
- ? field.type
21
- : `${field.type}:${field.plugin.key}`;
22
- const plugin = this.#plugins.get(identity);
23
- if (plugin === undefined || !plugin.canRender(field)) {
24
- throw new Error(`No admin field plugin can render ${field.path} (${identity})`);
25
- }
26
- return plugin;
27
+ component === undefined
28
+ ? `plugin:${field.plugin?.key}`
29
+ : `${field.type}:${component.plugin}:${component.component}`;
30
+ const renderer = this.#plugins.get(identity);
31
+ if (!renderer)
32
+ throw new Error(`No admin field renderer can render ${field.path} (${identity}).`);
33
+ return renderer;
27
34
  }
28
35
  }
29
-
30
- function pluginIdentity(plugin: FieldPlugin) {
31
- if (plugin.componentKey !== undefined)
32
- return `${plugin.type}:${plugin.key ?? "<missing>"}:${plugin.componentKey}`;
33
- return plugin.key === undefined ? plugin.type : `${plugin.type}:${plugin.key}`;
34
- }
35
-
36
- export function createCoreFieldRegistry(plugins: readonly FieldPlugin[] = []): FieldRegistry {
36
+ export function createCoreFieldRegistry(plugins: readonly AdminPlugin[] = []): FieldRegistry {
37
37
  const registry = new FieldRegistry();
38
- registry.register({ type: "text", canRender: (field) => field.text !== undefined });
39
- registry.register({ type: "code", canRender: (field) => field.code !== undefined });
40
- registry.register({ type: "select", canRender: (field) => field.select !== undefined });
41
- registry.register({ type: "radio", canRender: (field) => field.select !== undefined });
42
- registry.register({ type: "point", canRender: (field) => field.point !== undefined });
43
- registry.register({ type: "ui", canRender: (field) => field.ui !== undefined });
44
- registry.register({ type: "join", canRender: (field) => field.join !== undefined });
45
- registry.register({ type: "virtual", canRender: (field) => field.virtual !== undefined });
46
- for (const type of ["textarea", "email", "date", "number", "checkbox", "json"] as const) {
47
- registry.register({ type, canRender: () => true });
48
- }
49
- for (const type of ["group", "array"] as const) {
50
- registry.register({ type, canRender: (field) => field.nested !== undefined });
51
- }
52
- registry.register({ type: "blocks", canRender: (field) => field.blocks !== undefined });
53
- registry.register({
54
- type: "relationship",
55
- canRender: (field) => field.relationship !== undefined,
56
- });
57
- registry.register({ type: "upload", canRender: (field) => field.upload !== undefined });
58
- for (const plugin of plugins) registry.register(plugin);
38
+ for (const field of resolvePluginFields(plugins)) registry.register(field);
59
39
  return registry;
60
40
  }