@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.
- package/package.json +11 -9
- package/src/app.css +63 -4
- package/src/app.svelte +12 -14
- package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
- package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
- package/src/components/ui/select/select-trigger.svelte +1 -1
- package/src/core/forms/embedded-fields.ts +132 -0
- package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
- package/src/core/forms/field-editor-authoring.ts +47 -0
- package/src/core/forms/field-editor-binding.ts +228 -0
- package/src/core/forms/field-issue-focus.ts +42 -0
- package/src/core/forms/field-occurrence.ts +122 -0
- package/src/core/forms/form-controller.svelte.ts +475 -29
- package/src/core/forms/form-issue-correlation.ts +159 -0
- package/src/core/forms/form-schema.ts +211 -17
- package/src/core/forms/form-validation.ts +183 -17
- package/src/core/forms/live-validation.svelte.ts +317 -0
- package/src/core/forms/plugin-field-authoring.ts +160 -0
- package/src/core/forms/plugin-field-binding.ts +258 -0
- package/src/core/forms/plugin-field-write.ts +57 -0
- package/src/core/i18n/admin-i18n.svelte.ts +8 -1
- package/src/core/i18n/localized-schema.ts +33 -10
- package/src/core/plugins/field-registry.ts +29 -49
- package/src/core/plugins/row-label-registry.ts +25 -5
- package/src/core/runtime/admin-runtime.svelte.ts +25 -13
- package/src/features/auth/account-route.svelte +10 -5
- package/src/features/auth/account-security-route.svelte +1 -1
- package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
- package/src/features/collections/collection-list-controller.svelte.ts +5 -5
- package/src/features/collections/collection-list-filter-popover.svelte +6 -6
- package/src/features/collections/collection-list-route.svelte +13 -14
- package/src/features/collections/list-workspace.ts +22 -8
- package/src/features/documents/document-controller.svelte.ts +8 -2
- package/src/features/documents/document-route.svelte +36 -43
- package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
- package/src/features/reference-browser/reference-browser.svelte +215 -189
- package/src/features/uploads/upload-metadata-fields.svelte +6 -6
- package/src/features/versions/version-history-route.svelte +1 -1
- package/src/fields/embedded-schema-draft-editor.svelte +74 -0
- package/src/fields/embedded-schema-fields.svelte +44 -0
- package/src/fields/field-clipboard.ts +76 -16
- package/src/fields/field-collapsible.svelte +49 -0
- package/src/fields/field-layout.svelte +2 -18
- package/src/fields/field-messages.svelte +13 -18
- package/src/fields/field-renderer.svelte +53 -14
- package/src/fields/join/join-field.svelte +8 -1
- package/src/fields/live-validation-feedback.svelte +21 -0
- package/src/fields/local-field-editor.svelte +52 -0
- package/src/fields/nested/nested-field.svelte +159 -91
- package/src/fields/nested/scoped-field.ts +7 -9
- package/src/fields/plugin-field.svelte +156 -0
- package/src/fields/point/point-field.svelte +12 -2
- package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
- package/src/fields/primitive-list/primitive-number-input.ts +6 -0
- package/src/fields/radio/radio-field.svelte +13 -9
- package/src/fields/relationship/relationship-field.svelte +36 -12
- package/src/fields/relationship/relationship-query.ts +4 -2
- package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
- package/src/fields/scalar/date-control-value.ts +5 -5
- package/src/fields/scalar/date-value.ts +5 -5
- package/src/fields/scalar/scalar-field.svelte +47 -41
- package/src/fields/select/select-field.svelte +24 -24
- package/src/fields/select/select-value.ts +3 -3
- package/src/fields/text/slug-field.svelte +24 -8
- package/src/fields/text/text-field.svelte +8 -6
- 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
|
-
|
|
39
|
-
...
|
|
40
|
-
label: i18n.text(
|
|
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.
|
|
68
|
+
...(field.plugin?.embeddedTrees === undefined
|
|
68
69
|
? {}
|
|
69
70
|
: {
|
|
70
|
-
|
|
71
|
-
...field.
|
|
72
|
-
|
|
73
|
-
...
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
18
|
-
?
|
|
19
|
-
: field.plugin
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
}
|