@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
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LiveValidationController,
|
|
3
|
+
type LiveValidationTransport,
|
|
4
|
+
} from "@admin/core/forms/live-validation.svelte";
|
|
5
|
+
import type { LiveValidationRequest } from "@riducms/protocol";
|
|
6
|
+
import { embeddedOccurrences, type EmbeddedOccurrence } from "@admin/core/forms/embedded-fields";
|
|
7
|
+
import {
|
|
8
|
+
correlateEmbeddedIssues,
|
|
9
|
+
correlateFormIssues,
|
|
10
|
+
indexFieldValues,
|
|
11
|
+
canonicalIssueTarget,
|
|
12
|
+
} from "@admin/core/forms/form-issue-correlation";
|
|
1
13
|
import type { AccessCapabilitiesEnvelope, ValidationIssue } from "@riducms/protocol";
|
|
2
14
|
import type { SchemaField } from "@riducms/protocol";
|
|
3
15
|
import type { AdminI18n } from "@riducms/plugin";
|
|
@@ -6,6 +18,8 @@ import { createAdminI18n } from "@riducms/translations";
|
|
|
6
18
|
|
|
7
19
|
import {
|
|
8
20
|
cloneFormValues,
|
|
21
|
+
cloneFormValue,
|
|
22
|
+
initialFormValues,
|
|
9
23
|
localizationSource,
|
|
10
24
|
reconcileFormSchema,
|
|
11
25
|
recoverFormDraft,
|
|
@@ -14,7 +28,7 @@ import {
|
|
|
14
28
|
type FormValues,
|
|
15
29
|
submissionFormValues,
|
|
16
30
|
} from "@admin/core/forms/form-schema";
|
|
17
|
-
import { validateFormValues } from "@admin/core/forms/form-validation";
|
|
31
|
+
import { validateFormValues, unknownBlockIssues } from "@admin/core/forms/form-validation";
|
|
18
32
|
|
|
19
33
|
export type { DetachedDraftValue, FormValues } from "@admin/core/forms/form-schema";
|
|
20
34
|
|
|
@@ -52,35 +66,173 @@ export class FormValidationError extends Error {
|
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
export class FormController {
|
|
69
|
+
readonly liveValidation = new LiveValidationController(this);
|
|
70
|
+
#changeObservers = new Set<() => void>();
|
|
71
|
+
|
|
72
|
+
configureLiveValidation(transport: LiveValidationTransport) {
|
|
73
|
+
this.liveValidation.configure(transport);
|
|
74
|
+
}
|
|
75
|
+
requestLiveValidation(input: LiveValidationRequest, signal: AbortSignal) {
|
|
76
|
+
return this.liveValidation.request(input, signal);
|
|
77
|
+
}
|
|
78
|
+
observeChanges(observer: () => void) {
|
|
79
|
+
this.#changeObservers.add(observer);
|
|
80
|
+
return () => {
|
|
81
|
+
this.#changeObservers.delete(observer);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** A detached child also depends on unsaved values in each enclosing document scope. */
|
|
85
|
+
refreshLiveValidation() {
|
|
86
|
+
this.liveValidation.changed();
|
|
87
|
+
for (const observer of [...this.#changeObservers]) observer();
|
|
88
|
+
}
|
|
89
|
+
get enclosingLiveInputAvailable(): boolean {
|
|
90
|
+
return this.#readContext instanceof FormController
|
|
91
|
+
? this.#readContext.liveValidation.inputAvailable
|
|
92
|
+
: true;
|
|
93
|
+
}
|
|
94
|
+
setLiveInputUnavailable(path: string) {
|
|
95
|
+
const release = this.liveValidation.inputUnavailable(path);
|
|
96
|
+
for (const observer of [...this.#changeObservers]) observer();
|
|
97
|
+
return () => {
|
|
98
|
+
if (release()) this.refreshLiveValidation();
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Submit exact-locale edits with the same omission rules as Save, without client validation. */
|
|
102
|
+
liveValidationData() {
|
|
103
|
+
return submissionFormValues(this.#fields, this.values, (path, canonicalPath) =>
|
|
104
|
+
this.includesLiveValidation(path, canonicalPath)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
includesLiveValidation(path: string, canonicalPath: string) {
|
|
108
|
+
return (
|
|
109
|
+
this.canRead(path, canonicalPath) &&
|
|
110
|
+
this.canWrite(path, canonicalPath) &&
|
|
111
|
+
shouldSubmitLocalizedPath(
|
|
112
|
+
path,
|
|
113
|
+
this.values,
|
|
114
|
+
this.original,
|
|
115
|
+
this.contentLocale,
|
|
116
|
+
this.localizationSources,
|
|
117
|
+
this.#fields
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
editorEpoch = $state(0);
|
|
123
|
+
#editorScope: (() => boolean) | undefined;
|
|
124
|
+
/** Detached scopes consult their owner synchronously when a capability is used. */
|
|
125
|
+
setEditorScope(isActive: () => boolean) {
|
|
126
|
+
this.#editorScope = isActive;
|
|
127
|
+
}
|
|
128
|
+
get editorScopeActive() {
|
|
129
|
+
return this.#editorScope?.() ?? true;
|
|
130
|
+
}
|
|
131
|
+
#editorLifetimes = new Set<() => void>();
|
|
132
|
+
registerEditorLifetime(invalidate: () => void) {
|
|
133
|
+
this.#editorLifetimes.add(invalidate);
|
|
134
|
+
return () => {
|
|
135
|
+
this.#editorLifetimes.delete(invalidate);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
#invalidateEditors() {
|
|
139
|
+
this.liveValidation.reset();
|
|
140
|
+
this.#listEdits.clear();
|
|
141
|
+
this.editorEpoch += 1;
|
|
142
|
+
for (const invalidate of [...this.#editorLifetimes]) invalidate();
|
|
143
|
+
}
|
|
144
|
+
|
|
55
145
|
values = $state<FormValues>({});
|
|
56
146
|
original = $state<FormValues>({});
|
|
57
147
|
issues = $state<ValidationIssue[]>([]);
|
|
148
|
+
#submittingIssues = $state.raw<readonly ValidationIssue[]>([]);
|
|
58
149
|
submitting = $state(false);
|
|
59
150
|
revision = $state(0);
|
|
60
151
|
access = $state.raw<AccessCapabilitiesEnvelope>();
|
|
61
152
|
accessMode = $state<"create" | "update">("update");
|
|
62
|
-
writeBlocked = $state(false);
|
|
153
|
+
#writeBlocked = $state(false);
|
|
154
|
+
get writeBlocked() {
|
|
155
|
+
return this.#writeBlocked;
|
|
156
|
+
}
|
|
157
|
+
set writeBlocked(value: boolean) {
|
|
158
|
+
if (this.#writeBlocked === value) return;
|
|
159
|
+
this.#writeBlocked = value;
|
|
160
|
+
this.refreshLiveValidation();
|
|
161
|
+
}
|
|
63
162
|
resource = $state.raw<FormResource>();
|
|
64
163
|
contentLocale = $state<string>();
|
|
65
164
|
localizationSources = $state.raw<Readonly<Record<string, string>>>({});
|
|
66
|
-
#registered = new Set<
|
|
165
|
+
#registered = new Map<string, Set<symbol>>();
|
|
166
|
+
#rowMountKeys = new WeakMap<object, symbol>();
|
|
67
167
|
#pathObservers = new Map<string, Set<(value: unknown) => void>>();
|
|
68
168
|
#derivedTextBindings = new Map<string, DerivedTextBindingState>();
|
|
69
169
|
#revision = 0;
|
|
170
|
+
// One edit marker per primitive list, never per item. Even equal duplicate reorders
|
|
171
|
+
// invalidate positional feedback from an earlier submitted list.
|
|
172
|
+
#listEdits = new Map<string, number>();
|
|
173
|
+
#nextListEdit = 0;
|
|
174
|
+
#fields: readonly SchemaField[] = [];
|
|
70
175
|
#i18n: AdminI18n;
|
|
176
|
+
#readContext: Pick<FormController, "get" | "snapshot"> | undefined;
|
|
177
|
+
#pendingEdits = $state.raw<ReadonlyMap<symbol, () => readonly ValidationIssue[]>>(new Map());
|
|
71
178
|
|
|
72
|
-
constructor(
|
|
179
|
+
constructor(
|
|
180
|
+
values: FormValues = {},
|
|
181
|
+
i18n: AdminI18n = createAdminI18n(),
|
|
182
|
+
readContext?: Pick<FormController, "get" | "snapshot">
|
|
183
|
+
) {
|
|
73
184
|
this.#i18n = i18n;
|
|
185
|
+
this.#readContext = readContext;
|
|
74
186
|
this.reset(values);
|
|
75
187
|
}
|
|
76
188
|
|
|
189
|
+
/** Private admin host traversal; never passed to public plugin components. */
|
|
190
|
+
get schemaFields() {
|
|
191
|
+
return this.#fields;
|
|
192
|
+
}
|
|
193
|
+
disposeBindings() {
|
|
194
|
+
this.liveValidation.dispose();
|
|
195
|
+
// Logical revocation may be discovered by a derived read. This only runs lease cleanup.
|
|
196
|
+
for (const invalidate of [...this.#editorLifetimes]) invalidate();
|
|
197
|
+
}
|
|
198
|
+
|
|
77
199
|
get dirty() {
|
|
78
|
-
return
|
|
200
|
+
return (
|
|
201
|
+
this.pendingEditIssues().length > 0 ||
|
|
202
|
+
JSON.stringify(this.values) !== JSON.stringify(this.original)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Transient host-owned editing lock; unlike schema read-only, this is not presentation metadata. */
|
|
207
|
+
get editingBlocked() {
|
|
208
|
+
return this.submitting;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parent-owned detached edits participate in saving and navigation protection. */
|
|
212
|
+
registerPendingEdit(issues: () => readonly ValidationIssue[]) {
|
|
213
|
+
const token = Symbol("scoped-edit");
|
|
214
|
+
this.#pendingEdits = new Map(this.#pendingEdits).set(token, issues);
|
|
215
|
+
return () => {
|
|
216
|
+
const remaining = new Map(this.#pendingEdits);
|
|
217
|
+
remaining.delete(token);
|
|
218
|
+
this.#pendingEdits = remaining;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pendingEditIssues() {
|
|
223
|
+
return [...this.#pendingEdits.values()].flatMap((issues) => issues());
|
|
79
224
|
}
|
|
80
225
|
|
|
81
226
|
register(path: string) {
|
|
82
|
-
|
|
83
|
-
|
|
227
|
+
const token = Symbol(path);
|
|
228
|
+
const registrations = this.#registered.get(path) ?? new Set<symbol>();
|
|
229
|
+
registrations.add(token);
|
|
230
|
+
this.#registered.set(path, registrations);
|
|
231
|
+
return () => {
|
|
232
|
+
registrations.delete(token);
|
|
233
|
+
if (registrations.size === 0 && this.#registered.get(path) === registrations)
|
|
234
|
+
this.#registered.delete(path);
|
|
235
|
+
};
|
|
84
236
|
}
|
|
85
237
|
|
|
86
238
|
isRegistered(path: string) {
|
|
@@ -137,19 +289,77 @@ export class FormController {
|
|
|
137
289
|
};
|
|
138
290
|
}
|
|
139
291
|
|
|
140
|
-
get(path: string) {
|
|
292
|
+
get(path: string): unknown {
|
|
293
|
+
if (this.#readContext !== undefined && !Object.hasOwn(this.values, path.split(".")[0] ?? ""))
|
|
294
|
+
return this.#readContext.get(path);
|
|
141
295
|
return readPath(this.values, path);
|
|
142
296
|
}
|
|
143
297
|
|
|
144
|
-
|
|
145
|
-
|
|
298
|
+
/** DOM identity survives retained-row replacements, but not removal and key reuse. */
|
|
299
|
+
rowMountKey(row: Record<string, unknown>) {
|
|
300
|
+
const key = this.#rowMountKeys.get(row) ?? Symbol();
|
|
301
|
+
this.#rowMountKeys.set(row, key);
|
|
302
|
+
return key;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
snapshot(): FormValues {
|
|
306
|
+
return { ...this.#readContext?.snapshot(), ...cloneFormValues(this.values) };
|
|
146
307
|
}
|
|
147
308
|
|
|
148
309
|
set(path: string, value: unknown) {
|
|
310
|
+
if (this.#readContext !== undefined && !Object.hasOwn(this.values, path.split(".")[0] ?? ""))
|
|
311
|
+
throw new Error("Only fields in this scoped draft can be edited.");
|
|
312
|
+
const previous = this.get(path);
|
|
313
|
+
const [name] = path.split(".");
|
|
314
|
+
const root =
|
|
315
|
+
typeof previous === "object" ||
|
|
316
|
+
typeof value === "object" ||
|
|
317
|
+
/\.(?:_key|blockType)$/.test(path)
|
|
318
|
+
? this.#fields.find((field) => field.name === name)
|
|
319
|
+
: undefined;
|
|
320
|
+
// Use the same schema identities as issue/access correlation, including embedded payloads.
|
|
321
|
+
const listValues = new Map(
|
|
322
|
+
(root ? indexFieldValues([root], this.values) : []).flatMap(({ token, schema, value }) =>
|
|
323
|
+
schema?.type === "text-list" || schema?.type === "number-list"
|
|
324
|
+
? [[token, JSON.stringify(value)] as const]
|
|
325
|
+
: []
|
|
326
|
+
)
|
|
327
|
+
);
|
|
328
|
+
const mounts = new Map<string, { row: Record<string, unknown>; key: symbol }>();
|
|
329
|
+
if (root)
|
|
330
|
+
for (const { token, value: row } of indexFieldValues([root], this.values)) {
|
|
331
|
+
if (!isRecord(row)) continue;
|
|
332
|
+
const key = this.#rowMountKeys.get(row);
|
|
333
|
+
if (key) mounts.set(token, { row, key });
|
|
334
|
+
}
|
|
149
335
|
writePath(this.values, path, value);
|
|
336
|
+
if (root) {
|
|
337
|
+
const retained = new Set<string>();
|
|
338
|
+
for (const { token, schema, path: fieldPath, value: current } of indexFieldValues(
|
|
339
|
+
[root],
|
|
340
|
+
this.values
|
|
341
|
+
)) {
|
|
342
|
+
if (schema?.type !== "text-list" && schema?.type !== "number-list") continue;
|
|
343
|
+
retained.add(token);
|
|
344
|
+
if (fieldPath === path || listValues.get(token) !== JSON.stringify(current))
|
|
345
|
+
this.#listEdits.set(token, ++this.#nextListEdit);
|
|
346
|
+
}
|
|
347
|
+
for (const token of listValues.keys())
|
|
348
|
+
if (!retained.has(token)) this.#listEdits.delete(token);
|
|
349
|
+
}
|
|
350
|
+
if (root && mounts.size) {
|
|
351
|
+
for (const { row } of mounts.values()) this.#rowMountKeys.delete(row);
|
|
352
|
+
for (const { token, value: row } of indexFieldValues([root], this.values)) {
|
|
353
|
+
const mount = mounts.get(token);
|
|
354
|
+
if (mount && isRecord(row)) this.#rowMountKeys.set(row, mount.key);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
150
357
|
this.issues = this.issues.filter(
|
|
151
358
|
(issue) => issue.path !== path && !issue.path.startsWith(`${path}.`)
|
|
152
359
|
);
|
|
360
|
+
this.#submittingIssues = this.#submittingIssues.filter(
|
|
361
|
+
(issue) => issue.path !== path && !issue.path.startsWith(`${path}.`)
|
|
362
|
+
);
|
|
153
363
|
for (const [observedPath, observers] of this.#pathObservers) {
|
|
154
364
|
if (
|
|
155
365
|
observedPath !== path &&
|
|
@@ -161,28 +371,49 @@ export class FormController {
|
|
|
161
371
|
const observedValue = this.get(observedPath);
|
|
162
372
|
for (const observer of [...observers]) observer(observedValue);
|
|
163
373
|
}
|
|
374
|
+
this.liveValidation.changed(path);
|
|
375
|
+
for (const observer of [...this.#changeObservers]) observer();
|
|
164
376
|
}
|
|
165
377
|
|
|
166
378
|
issuesFor(path: string) {
|
|
167
|
-
|
|
379
|
+
const issues = new Map<string, ValidationIssue>();
|
|
380
|
+
for (const issue of [
|
|
381
|
+
...this.issues,
|
|
382
|
+
...this.#submittingIssues,
|
|
383
|
+
...this.liveValidation.issues,
|
|
384
|
+
]) {
|
|
385
|
+
if (issue.path !== path && !issue.path.startsWith(`${path}.`)) continue;
|
|
386
|
+
const key = JSON.stringify([issue.path, issue.code, issue.message]);
|
|
387
|
+
if (!issues.has(key)) issues.set(key, issue);
|
|
388
|
+
}
|
|
389
|
+
return [...issues.values()];
|
|
168
390
|
}
|
|
169
391
|
|
|
170
392
|
setAccess(access: AccessCapabilitiesEnvelope | undefined, mode: "create" | "update") {
|
|
171
393
|
this.access = access;
|
|
172
394
|
this.accessMode = mode;
|
|
395
|
+
this.refreshLiveValidation();
|
|
173
396
|
}
|
|
174
397
|
|
|
175
398
|
setResource(resource: FormResource | undefined) {
|
|
399
|
+
if (JSON.stringify(this.resource) !== JSON.stringify(resource)) this.#invalidateEditors();
|
|
176
400
|
this.resource = resource;
|
|
177
401
|
}
|
|
178
402
|
|
|
179
403
|
setLocalization(locale: string | undefined, sources: Readonly<Record<string, string>> = {}) {
|
|
404
|
+
if (this.contentLocale !== locale) this.#invalidateEditors();
|
|
180
405
|
this.contentLocale = locale;
|
|
181
406
|
this.localizationSources = sources;
|
|
182
407
|
}
|
|
183
408
|
|
|
184
409
|
localizationSource(path: string) {
|
|
185
|
-
return localizationSource(
|
|
410
|
+
return localizationSource(
|
|
411
|
+
path,
|
|
412
|
+
this.values,
|
|
413
|
+
this.original,
|
|
414
|
+
this.localizationSources,
|
|
415
|
+
this.#fields
|
|
416
|
+
);
|
|
186
417
|
}
|
|
187
418
|
|
|
188
419
|
isInherited(path: string) {
|
|
@@ -210,22 +441,109 @@ export class FormController {
|
|
|
210
441
|
: (field?.update ?? access.operations.update);
|
|
211
442
|
}
|
|
212
443
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
Object.entries(access.fields).filter(([candidate]) => !isIndexedChildPath(path, candidate))
|
|
444
|
+
/** Keep occurrence capabilities attached to retained rows during structural edits. */
|
|
445
|
+
setRows(path: string, rows: Record<string, unknown>[]) {
|
|
446
|
+
const issues = this.issues.filter(
|
|
447
|
+
(issue) => issue.path === path || issue.path.startsWith(`${path}.`)
|
|
218
448
|
);
|
|
219
|
-
|
|
220
|
-
|
|
449
|
+
const submitted = issues.length === 0 ? undefined : this.snapshot();
|
|
450
|
+
const access = this.access;
|
|
451
|
+
if (access !== undefined) {
|
|
452
|
+
const previous = this.get(path);
|
|
453
|
+
const before = Array.isArray(previous) ? previous : [];
|
|
454
|
+
const oldIndexes = uniqueRowIndexes(before);
|
|
455
|
+
const newIndexes = uniqueRowIndexes(rows);
|
|
456
|
+
const fields: AccessCapabilitiesEnvelope["fields"] = {};
|
|
457
|
+
const prefix = `${path}.`;
|
|
458
|
+
for (const [candidate, capability] of Object.entries(access.fields)) {
|
|
459
|
+
if (!isIndexedChildPath(path, candidate)) {
|
|
460
|
+
fields[candidate] = capability;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const index = candidate.slice(prefix.length).split(".", 1)[0]!;
|
|
464
|
+
const oldRow = before[Number(index)];
|
|
465
|
+
if (!isRecord(oldRow) || typeof oldRow._key !== "string") continue;
|
|
466
|
+
const nextIndex = newIndexes.get(oldRow._key);
|
|
467
|
+
if (
|
|
468
|
+
oldIndexes.get(oldRow._key) !== Number(index) ||
|
|
469
|
+
nextIndex === undefined ||
|
|
470
|
+
nextIndex < 0
|
|
471
|
+
)
|
|
472
|
+
continue;
|
|
473
|
+
// Replacing a variant is a new occurrence even if a caller reuses its key.
|
|
474
|
+
if (rows[nextIndex]?.blockType !== oldRow.blockType) continue;
|
|
475
|
+
fields[`${prefix}${nextIndex}${candidate.slice(prefix.length + index.length)}`] =
|
|
476
|
+
capability;
|
|
477
|
+
}
|
|
478
|
+
this.access = { ...access, fields };
|
|
479
|
+
}
|
|
480
|
+
this.set(path, rows);
|
|
481
|
+
if (submitted !== undefined)
|
|
482
|
+
this.issues = [
|
|
483
|
+
...this.issues,
|
|
484
|
+
...correlateFormIssues(this.#fields, submitted, this.values, issues),
|
|
485
|
+
];
|
|
221
486
|
}
|
|
222
487
|
|
|
223
|
-
|
|
488
|
+
/** Initialize new payloads and rebase access when a plugin replaces its envelope. */
|
|
489
|
+
setEmbedded(field: SchemaField, value: unknown, rebaseAccess = true) {
|
|
490
|
+
const retainedIssues = correlateEmbeddedIssues(field, this.get(field.path), value, this.issues);
|
|
491
|
+
const before = embeddedOccurrences(field, this.get(field.path));
|
|
492
|
+
value = cloneFormValue(value);
|
|
493
|
+
const after = embeddedOccurrences(field, value);
|
|
494
|
+
if (before.issues.length === 0 && after.issues.length === 0) {
|
|
495
|
+
const retained = new Set(before.occurrences.map(embeddedOccurrenceKey));
|
|
496
|
+
for (const occurrence of after.occurrences) {
|
|
497
|
+
if (occurrence.identity !== undefined && retained.has(embeddedOccurrenceKey(occurrence)))
|
|
498
|
+
continue;
|
|
499
|
+
Object.assign(
|
|
500
|
+
occurrence.payload,
|
|
501
|
+
initialFormValues(occurrence.block.fields, occurrence.payload)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (
|
|
506
|
+
rebaseAccess &&
|
|
507
|
+
this.access !== undefined &&
|
|
508
|
+
before.issues.length === 0 &&
|
|
509
|
+
after.issues.length === 0
|
|
510
|
+
) {
|
|
511
|
+
const previousPaths = new Map(
|
|
512
|
+
before.occurrences.map((occurrence) => [occurrence.path, occurrence])
|
|
513
|
+
);
|
|
514
|
+
const nextIdentities = new Map(
|
|
515
|
+
after.occurrences
|
|
516
|
+
.filter((occurrence) => occurrence.identity !== undefined)
|
|
517
|
+
.map((occurrence) => [embeddedOccurrenceKey(occurrence), occurrence])
|
|
518
|
+
);
|
|
519
|
+
const fields: AccessCapabilitiesEnvelope["fields"] = {};
|
|
520
|
+
for (const [path, capability] of Object.entries(this.access.fields)) {
|
|
521
|
+
let parentPath = path;
|
|
522
|
+
while (!previousPaths.has(parentPath) && parentPath.includes("."))
|
|
523
|
+
parentPath = parentPath.slice(0, parentPath.lastIndexOf("."));
|
|
524
|
+
const occurrence = previousPaths.get(parentPath);
|
|
525
|
+
if (occurrence === undefined) {
|
|
526
|
+
fields[path] = capability;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const next = nextIdentities.get(embeddedOccurrenceKey(occurrence));
|
|
530
|
+
if (next !== undefined) fields[next.path + path.slice(occurrence.path.length)] = capability;
|
|
531
|
+
}
|
|
532
|
+
this.access = { ...this.access, fields };
|
|
533
|
+
}
|
|
534
|
+
this.set(field.path, value);
|
|
535
|
+
this.issues = [...this.issues, ...retainedIssues];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
reset(values: FormValues, fields: readonly SchemaField[] = this.#fields) {
|
|
539
|
+
this.#fields = fields;
|
|
540
|
+
this.#invalidateEditors();
|
|
224
541
|
this.#clearDerivedTextBindings();
|
|
225
542
|
this.values = cloneFormValues(values);
|
|
226
543
|
this.original = cloneFormValues(values);
|
|
227
544
|
this.localizationSources = {};
|
|
228
545
|
this.issues = [];
|
|
546
|
+
this.#submittingIssues = [];
|
|
229
547
|
this.submitting = false;
|
|
230
548
|
this.revision = ++this.#revision;
|
|
231
549
|
}
|
|
@@ -235,11 +553,14 @@ export class FormController {
|
|
|
235
553
|
nextFields: readonly SchemaField[],
|
|
236
554
|
initializeDefaults = false
|
|
237
555
|
) {
|
|
556
|
+
this.#invalidateEditors();
|
|
238
557
|
this.#clearDerivedTextBindings();
|
|
558
|
+
this.#fields = nextFields;
|
|
239
559
|
const result = reconcileFormSchema(this, previousFields, nextFields, { initializeDefaults });
|
|
240
560
|
this.values = result.values;
|
|
241
561
|
this.original = result.original;
|
|
242
562
|
this.issues = [];
|
|
563
|
+
this.#submittingIssues = [];
|
|
243
564
|
this.revision = ++this.#revision;
|
|
244
565
|
return { detached: result.detached, restoredFields: 0 };
|
|
245
566
|
}
|
|
@@ -249,11 +570,14 @@ export class FormController {
|
|
|
249
570
|
previousFields: readonly SchemaField[],
|
|
250
571
|
nextFields: readonly SchemaField[]
|
|
251
572
|
) {
|
|
573
|
+
this.#invalidateEditors();
|
|
252
574
|
this.#clearDerivedTextBindings();
|
|
575
|
+
this.#fields = nextFields;
|
|
253
576
|
const result = recoverFormDraft(this, draft, previousFields, nextFields);
|
|
254
577
|
this.values = result.values;
|
|
255
578
|
this.original = result.original;
|
|
256
579
|
this.issues = [];
|
|
580
|
+
this.#submittingIssues = [];
|
|
257
581
|
this.revision = ++this.#revision;
|
|
258
582
|
return { detached: result.detached, restoredFields: result.restoredFields };
|
|
259
583
|
}
|
|
@@ -263,6 +587,20 @@ export class FormController {
|
|
|
263
587
|
requireMissing: boolean,
|
|
264
588
|
operation: (values: FormValues) => Promise<T>
|
|
265
589
|
) {
|
|
590
|
+
// Save owns the next validation result, but cancelling advisory work must not
|
|
591
|
+
// make its current feedback disappear while the request is in flight.
|
|
592
|
+
const retainedIssues = uniqueValidationIssues([
|
|
593
|
+
...this.issues,
|
|
594
|
+
...this.#submittingIssues,
|
|
595
|
+
...this.liveValidation.issues,
|
|
596
|
+
]);
|
|
597
|
+
this.liveValidation.suspend();
|
|
598
|
+
const pendingIssues = this.pendingEditIssues();
|
|
599
|
+
if (pendingIssues.length > 0) {
|
|
600
|
+
this.issues = [...pendingIssues];
|
|
601
|
+
throw new FormValidationError(pendingIssues, this.#i18n);
|
|
602
|
+
}
|
|
603
|
+
this.#fields = fields;
|
|
266
604
|
const controllerRevision = this.#revision;
|
|
267
605
|
const include = (path: string, canonicalPath: string) =>
|
|
268
606
|
this.canRead(path, canonicalPath) &&
|
|
@@ -272,29 +610,53 @@ export class FormController {
|
|
|
272
610
|
this.values,
|
|
273
611
|
this.original,
|
|
274
612
|
this.contentLocale,
|
|
275
|
-
this.localizationSources
|
|
613
|
+
this.localizationSources,
|
|
614
|
+
fields
|
|
276
615
|
);
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
616
|
+
const unknownIssues = [
|
|
617
|
+
...unknownBlockIssues(fields, this.original, this.#i18n),
|
|
618
|
+
...unknownBlockIssues(fields, this.values, this.#i18n),
|
|
619
|
+
];
|
|
620
|
+
const clientIssues =
|
|
621
|
+
unknownIssues.length > 0
|
|
622
|
+
? unknownIssues
|
|
623
|
+
: validateFormValues(fields, this.values, {
|
|
624
|
+
requireMissing,
|
|
625
|
+
include,
|
|
626
|
+
i18n: this.#i18n,
|
|
627
|
+
});
|
|
282
628
|
if (clientIssues.length > 0) {
|
|
283
629
|
this.issues = clientIssues;
|
|
284
630
|
throw new FormValidationError(clientIssues, this.#i18n);
|
|
285
631
|
}
|
|
286
632
|
this.submitting = true;
|
|
287
633
|
this.issues = [];
|
|
634
|
+
this.#submittingIssues = retainedIssues;
|
|
635
|
+
const submittedValues = cloneFormValues(this.values);
|
|
636
|
+
const retainIssueOccurrences = this.#captureIssueLifetime(fields);
|
|
288
637
|
try {
|
|
289
638
|
const result = await operation(submissionFormValues(fields, this.values, include));
|
|
290
639
|
if (this.#revision === controllerRevision) {
|
|
291
|
-
this
|
|
640
|
+
this.#invalidateEditors();
|
|
641
|
+
this.original = cloneFormValues(submittedValues);
|
|
642
|
+
this.issues = [];
|
|
643
|
+
this.#submittingIssues = [];
|
|
644
|
+
this.submitting = false;
|
|
292
645
|
this.revision = ++this.#revision;
|
|
293
646
|
}
|
|
294
647
|
return result;
|
|
295
648
|
} catch (error) {
|
|
296
|
-
if (this.#revision === controllerRevision
|
|
297
|
-
this.issues =
|
|
649
|
+
if (this.#revision === controllerRevision) {
|
|
650
|
+
this.issues =
|
|
651
|
+
error instanceof RiduError && error.issues.length > 0
|
|
652
|
+
? correlateFormIssues(
|
|
653
|
+
fields,
|
|
654
|
+
submittedValues,
|
|
655
|
+
this.values,
|
|
656
|
+
retainIssueOccurrences(error.issues)
|
|
657
|
+
)
|
|
658
|
+
: [...this.#submittingIssues];
|
|
659
|
+
this.#submittingIssues = [];
|
|
298
660
|
}
|
|
299
661
|
throw error;
|
|
300
662
|
} finally {
|
|
@@ -302,6 +664,54 @@ export class FormController {
|
|
|
302
664
|
}
|
|
303
665
|
}
|
|
304
666
|
|
|
667
|
+
#captureIssueLifetime(fields: readonly SchemaField[]) {
|
|
668
|
+
const epoch = this.editorEpoch;
|
|
669
|
+
const locations = indexFieldValues(fields, this.values);
|
|
670
|
+
const listEdits = new Map(
|
|
671
|
+
locations.flatMap(({ token, schema }) =>
|
|
672
|
+
schema?.type === "text-list" || schema?.type === "number-list"
|
|
673
|
+
? [[token, this.#listEdits.get(token)] as const]
|
|
674
|
+
: []
|
|
675
|
+
)
|
|
676
|
+
);
|
|
677
|
+
const paths = new Map(locations.map((location) => [location.path, location.token]));
|
|
678
|
+
// Reuse the controller's existing mount identities: removing and reinserting
|
|
679
|
+
// the same serialized key cannot resurrect a pending authoritative issue.
|
|
680
|
+
const mounts = new Map(
|
|
681
|
+
locations.flatMap(({ token, value }) =>
|
|
682
|
+
isRecord(value) ? [[token, this.rowMountKey(value)] as const] : []
|
|
683
|
+
)
|
|
684
|
+
);
|
|
685
|
+
return (issues: readonly ValidationIssue[]) => {
|
|
686
|
+
if (this.editorEpoch !== epoch) return [];
|
|
687
|
+
const current = new Map(
|
|
688
|
+
indexFieldValues(fields, this.values).flatMap(({ token, value }) =>
|
|
689
|
+
isRecord(value) ? [[token, this.rowMountKey(value)] as const] : []
|
|
690
|
+
)
|
|
691
|
+
);
|
|
692
|
+
const expired = [...mounts].flatMap(([token, mount]) =>
|
|
693
|
+
current.get(token) === mount ? [] : [token.slice(0, -1)]
|
|
694
|
+
);
|
|
695
|
+
return issues.filter((issue) => {
|
|
696
|
+
let path = issue.path;
|
|
697
|
+
while (!paths.has(path) && path.includes(".")) path = path.slice(0, path.lastIndexOf("."));
|
|
698
|
+
const token =
|
|
699
|
+
issue.target === undefined ? paths.get(path) : canonicalIssueTarget(issue.target);
|
|
700
|
+
if (issue.target !== undefined && token === undefined) return false;
|
|
701
|
+
if (
|
|
702
|
+
token !== undefined &&
|
|
703
|
+
listEdits.has(token) &&
|
|
704
|
+
listEdits.get(token) !== this.#listEdits.get(token)
|
|
705
|
+
)
|
|
706
|
+
return false;
|
|
707
|
+
return (
|
|
708
|
+
token === undefined ||
|
|
709
|
+
!expired.some((prefix) => token === `${prefix}]` || token.startsWith(`${prefix},`))
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
305
715
|
#setDerivedText(path: string, value: string) {
|
|
306
716
|
if (String(this.get(path) ?? "") !== value) this.set(path, value);
|
|
307
717
|
}
|
|
@@ -325,6 +735,33 @@ function isIndexedChildPath(parent: string, candidate: string) {
|
|
|
325
735
|
return /^\d+$/.test(candidate.slice(prefix.length).split(".", 1)[0] ?? "");
|
|
326
736
|
}
|
|
327
737
|
|
|
738
|
+
function uniqueRowIndexes(rows: readonly unknown[]) {
|
|
739
|
+
const indexes = new Map<string, number>();
|
|
740
|
+
rows.forEach((row, index) => {
|
|
741
|
+
if (isRecord(row) && typeof row._key === "string" && row._key.trim() !== "")
|
|
742
|
+
indexes.set(row._key, indexes.has(row._key) ? -1 : index);
|
|
743
|
+
});
|
|
744
|
+
return indexes;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function uniqueValidationIssues(issues: readonly ValidationIssue[]) {
|
|
748
|
+
const unique = new Map<string, ValidationIssue>();
|
|
749
|
+
for (const issue of issues) {
|
|
750
|
+
const key = JSON.stringify([
|
|
751
|
+
issue.path,
|
|
752
|
+
issue.code,
|
|
753
|
+
issue.message,
|
|
754
|
+
issue.target,
|
|
755
|
+
issue.fieldId,
|
|
756
|
+
issue.collectionId,
|
|
757
|
+
issue.globalId,
|
|
758
|
+
issue.locale,
|
|
759
|
+
]);
|
|
760
|
+
if (!unique.has(key)) unique.set(key, issue);
|
|
761
|
+
}
|
|
762
|
+
return [...unique.values()];
|
|
763
|
+
}
|
|
764
|
+
|
|
328
765
|
function readPath(values: FormValues, path: string) {
|
|
329
766
|
let current: unknown = values;
|
|
330
767
|
for (const segment of path.split(".")) {
|
|
@@ -367,3 +804,12 @@ function setChild(container: FormValues | unknown[], segment: string, value: unk
|
|
|
367
804
|
function isRecord(value: unknown): value is FormValues {
|
|
368
805
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
369
806
|
}
|
|
807
|
+
|
|
808
|
+
function embeddedOccurrenceKey(occurrence: EmbeddedOccurrence) {
|
|
809
|
+
return JSON.stringify([
|
|
810
|
+
occurrence.tree.key,
|
|
811
|
+
occurrence.case.tagValue,
|
|
812
|
+
occurrence.block.slug,
|
|
813
|
+
occurrence.identity,
|
|
814
|
+
]);
|
|
815
|
+
}
|