@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
@@ -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<string>();
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(values: FormValues = {}, i18n: AdminI18n = createAdminI18n()) {
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 JSON.stringify(this.values) !== JSON.stringify(this.original);
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
- this.#registered.add(path);
83
- return () => this.#registered.delete(path);
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
- snapshot() {
145
- return cloneFormValues(this.values);
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
- return this.issues.filter((issue) => issue.path === path || issue.path.startsWith(`${path}.`));
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(path, this.values, this.original, this.localizationSources);
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
- invalidateIndexedFieldCapabilities(path: string) {
214
- const access = this.access;
215
- if (access === undefined) return;
216
- const fields = Object.fromEntries(
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
- if (Object.keys(fields).length === Object.keys(access.fields).length) return;
220
- this.access = { ...access, fields };
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
- reset(values: FormValues) {
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 clientIssues = validateFormValues(fields, this.values, {
278
- requireMissing,
279
- include,
280
- i18n: this.#i18n,
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.original = cloneFormValues(this.values);
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 && error instanceof RiduError) {
297
- this.issues = [...error.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
+ }