@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,4 +1,7 @@
|
|
|
1
|
+
import { resolveBlockTypes } from "@riducms/protocol";
|
|
2
|
+
import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
|
|
1
3
|
import type { AdminI18n } from "@riducms/plugin";
|
|
4
|
+
import { createAdminI18n } from "@riducms/translations";
|
|
2
5
|
import type { SchemaField, ValidationIssue } from "@riducms/protocol";
|
|
3
6
|
|
|
4
7
|
import { initialFormValues, type FormValues } from "@admin/core/forms/form-schema";
|
|
@@ -92,7 +95,7 @@ function resolveFieldLabel(
|
|
|
92
95
|
),
|
|
93
96
|
]
|
|
94
97
|
: labels;
|
|
95
|
-
const blockFields = field.blocks.
|
|
98
|
+
const blockFields = resolveBlockTypes(field.blocks).flatMap((block) => block.fields);
|
|
96
99
|
return resolveFieldLabel(
|
|
97
100
|
blockFields,
|
|
98
101
|
isIndex(index) ? childSegments : remaining,
|
|
@@ -126,8 +129,14 @@ function validateFields(
|
|
|
126
129
|
const defaults = requireMissing ? initialFormValues([field]) : {};
|
|
127
130
|
if (Object.hasOwn(defaults, field.name)) {
|
|
128
131
|
validateFields([field], defaults, prefix, true, issues, include, i18n);
|
|
129
|
-
} else if (requireMissing && field.required) {
|
|
132
|
+
} else if (requireMissing && field.required && !field.dynamicDefault) {
|
|
130
133
|
issues.push(requiredIssue(field, path, i18n));
|
|
134
|
+
} else if (
|
|
135
|
+
requireMissing &&
|
|
136
|
+
!field.dynamicDefault &&
|
|
137
|
+
(field.type === "text-list" || field.type === "number-list")
|
|
138
|
+
) {
|
|
139
|
+
validatePrimitiveList(field, [], path, issues, i18n);
|
|
131
140
|
} else if (requireMissing && field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
|
|
132
141
|
issues.push(
|
|
133
142
|
issue(
|
|
@@ -146,6 +155,8 @@ function validateFields(
|
|
|
146
155
|
}
|
|
147
156
|
if (value === null || value === undefined) {
|
|
148
157
|
if (field.required) issues.push(requiredIssue(field, path, i18n));
|
|
158
|
+
else if (field.type === "text-list" || field.type === "number-list")
|
|
159
|
+
validatePrimitiveList(field, [], path, issues, i18n);
|
|
149
160
|
else if (field.type === "array" && (field.nested?.minRows ?? 0) > 0) {
|
|
150
161
|
issues.push(
|
|
151
162
|
issue(
|
|
@@ -164,6 +175,10 @@ function validateFields(
|
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
switch (field.type) {
|
|
178
|
+
case "text-list":
|
|
179
|
+
case "number-list":
|
|
180
|
+
validatePrimitiveList(field, value, path, issues, i18n);
|
|
181
|
+
break;
|
|
167
182
|
case "text":
|
|
168
183
|
case "code":
|
|
169
184
|
case "textarea":
|
|
@@ -242,14 +257,75 @@ function validateFields(
|
|
|
242
257
|
case "blocks":
|
|
243
258
|
validateBlocks(field, value, path, issues, include, i18n);
|
|
244
259
|
break;
|
|
260
|
+
case "plugin": {
|
|
261
|
+
const embedded = embeddedOccurrences(field, value, path);
|
|
262
|
+
issues.push(...embedded.issues);
|
|
263
|
+
for (const occurrence of embedded.occurrences)
|
|
264
|
+
validateFields(
|
|
265
|
+
occurrence.block.fields,
|
|
266
|
+
occurrence.payload,
|
|
267
|
+
occurrence.path,
|
|
268
|
+
true,
|
|
269
|
+
issues,
|
|
270
|
+
include,
|
|
271
|
+
i18n
|
|
272
|
+
);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
245
275
|
case "json":
|
|
246
|
-
|
|
247
|
-
// Plugin validators and internal populated-document checks remain server-authoritative.
|
|
276
|
+
// Plugin envelope validators and populated-document checks remain server-authoritative.
|
|
248
277
|
break;
|
|
249
278
|
}
|
|
250
279
|
}
|
|
251
280
|
}
|
|
252
281
|
|
|
282
|
+
function validatePrimitiveList(
|
|
283
|
+
field: SchemaField,
|
|
284
|
+
value: unknown,
|
|
285
|
+
path: string,
|
|
286
|
+
issues: ValidationIssue[],
|
|
287
|
+
i18n: AdminI18n = createAdminI18n()
|
|
288
|
+
) {
|
|
289
|
+
const label = localizedFieldLabel(field, i18n);
|
|
290
|
+
const report = (
|
|
291
|
+
code: string,
|
|
292
|
+
key: Parameters<AdminI18n["t"]>[0],
|
|
293
|
+
variables: Readonly<Record<string, string | number>> = {}
|
|
294
|
+
) => issues.push(issue(code, path, i18n.t(key, { label, ...variables })));
|
|
295
|
+
if (!Array.isArray(value)) {
|
|
296
|
+
report("invalid_type", "errors:array");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const minimum = Math.max(field.required ? 1 : 0, field.list?.minRows ?? 0);
|
|
300
|
+
if (value.length < minimum)
|
|
301
|
+
report(field.required && value.length === 0 ? "required" : "min_rows", "errors:listMinItems", {
|
|
302
|
+
count: minimum,
|
|
303
|
+
});
|
|
304
|
+
if (field.list?.maxRows && value.length > field.list.maxRows)
|
|
305
|
+
report("max_rows", "errors:listMaxItems", { count: field.list.maxRows });
|
|
306
|
+
for (const [index, item] of value.entries()) {
|
|
307
|
+
const number = index + 1;
|
|
308
|
+
if (field.type === "text-list") {
|
|
309
|
+
if (typeof item !== "string") {
|
|
310
|
+
report("invalid_type", "errors:listItemString", { number });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const length = Array.from(item).length;
|
|
314
|
+
if (length < (field.text?.minLength ?? 0))
|
|
315
|
+
report("min_length", "errors:listItemMinLength", { number, count: field.text!.minLength! });
|
|
316
|
+
if (field.text?.maxLength !== undefined && length > field.text.maxLength)
|
|
317
|
+
report("max_length", "errors:listItemMaxLength", { number, count: field.text.maxLength });
|
|
318
|
+
} else if (typeof item !== "number" || !Number.isFinite(item))
|
|
319
|
+
report("invalid_number", "errors:listItemNumber", { number });
|
|
320
|
+
else {
|
|
321
|
+
if (field.number?.min !== undefined && item < field.number.min)
|
|
322
|
+
report("min_value", "errors:listItemMin", { number, min: field.number.min });
|
|
323
|
+
if (field.number?.max !== undefined && item > field.number.max)
|
|
324
|
+
report("max_value", "errors:listItemMax", { number, max: field.number.max });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
253
329
|
function validateString(
|
|
254
330
|
field: SchemaField,
|
|
255
331
|
value: unknown,
|
|
@@ -330,16 +406,16 @@ function validateSelect(
|
|
|
330
406
|
if (field.required) issues.push(requiredIssue(field, path, i18n));
|
|
331
407
|
return;
|
|
332
408
|
}
|
|
333
|
-
if (!field.select?.
|
|
409
|
+
if (!field.select?.options.some((option) => option.value === value)) {
|
|
334
410
|
issues.push(
|
|
335
411
|
issue(
|
|
336
|
-
"
|
|
412
|
+
"invalid_option",
|
|
337
413
|
path,
|
|
338
414
|
translate(
|
|
339
415
|
i18n,
|
|
340
|
-
"errors:
|
|
416
|
+
"errors:allowedOption",
|
|
341
417
|
{ label: localizedFieldLabel(field, i18n) },
|
|
342
|
-
`${localizedFieldLabel(field, i18n)} is not an allowed
|
|
418
|
+
`${localizedFieldLabel(field, i18n)} is not an allowed option`
|
|
343
419
|
)
|
|
344
420
|
)
|
|
345
421
|
);
|
|
@@ -373,7 +449,7 @@ function validateSelectMany(
|
|
|
373
449
|
return;
|
|
374
450
|
}
|
|
375
451
|
|
|
376
|
-
const
|
|
452
|
+
const options = new Set((field.select?.options ?? []).map((option) => option.value));
|
|
377
453
|
const seen = new Set<string>();
|
|
378
454
|
for (const [index, candidate] of value.entries()) {
|
|
379
455
|
const candidatePath = `${path}.${index}`;
|
|
@@ -392,16 +468,16 @@ function validateSelectMany(
|
|
|
392
468
|
);
|
|
393
469
|
continue;
|
|
394
470
|
}
|
|
395
|
-
if (!
|
|
471
|
+
if (!options.has(candidate)) {
|
|
396
472
|
issues.push(
|
|
397
473
|
issue(
|
|
398
|
-
"
|
|
474
|
+
"invalid_option",
|
|
399
475
|
candidatePath,
|
|
400
476
|
translate(
|
|
401
477
|
i18n,
|
|
402
|
-
"errors:
|
|
478
|
+
"errors:allowedOption",
|
|
403
479
|
{ label: localizedFieldLabel(field, i18n) },
|
|
404
|
-
`${localizedFieldLabel(field, i18n)} is not an allowed
|
|
480
|
+
`${localizedFieldLabel(field, i18n)} is not an allowed option`
|
|
405
481
|
)
|
|
406
482
|
)
|
|
407
483
|
);
|
|
@@ -409,13 +485,13 @@ function validateSelectMany(
|
|
|
409
485
|
if (seen.has(candidate)) {
|
|
410
486
|
issues.push(
|
|
411
487
|
issue(
|
|
412
|
-
"
|
|
488
|
+
"duplicate_option",
|
|
413
489
|
candidatePath,
|
|
414
490
|
translate(
|
|
415
491
|
i18n,
|
|
416
|
-
"errors:
|
|
492
|
+
"errors:duplicateOption",
|
|
417
493
|
{ label: localizedFieldLabel(field, i18n) },
|
|
418
|
-
`${localizedFieldLabel(field, i18n)} must not contain duplicate
|
|
494
|
+
`${localizedFieldLabel(field, i18n)} must not contain duplicate options`
|
|
419
495
|
)
|
|
420
496
|
)
|
|
421
497
|
);
|
|
@@ -684,6 +760,37 @@ function validateBlocks(
|
|
|
684
760
|
)
|
|
685
761
|
);
|
|
686
762
|
}
|
|
763
|
+
const minRows = field.blocks.minRows ?? 0;
|
|
764
|
+
const maxRows = field.blocks.maxRows ?? 0;
|
|
765
|
+
if (value.length < minRows && !(field.required && value.length === 0)) {
|
|
766
|
+
issues.push(
|
|
767
|
+
issue(
|
|
768
|
+
"min_rows",
|
|
769
|
+
path,
|
|
770
|
+
translate(
|
|
771
|
+
i18n,
|
|
772
|
+
"errors:minRows",
|
|
773
|
+
{ label: localizedFieldLabel(field, i18n), count: minRows },
|
|
774
|
+
`${localizedFieldLabel(field, i18n)} must contain at least ${minRows} rows`
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
if (maxRows > 0 && value.length > maxRows) {
|
|
780
|
+
issues.push(
|
|
781
|
+
issue(
|
|
782
|
+
"max_rows",
|
|
783
|
+
path,
|
|
784
|
+
translate(
|
|
785
|
+
i18n,
|
|
786
|
+
"errors:maxRows",
|
|
787
|
+
{ label: localizedFieldLabel(field, i18n), count: maxRows },
|
|
788
|
+
`${localizedFieldLabel(field, i18n)} must contain no more than ${maxRows} rows`
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
687
794
|
for (const [index, row] of value.entries()) {
|
|
688
795
|
const rowPath = `${path}.${index}`;
|
|
689
796
|
if (!isRecord(row)) {
|
|
@@ -696,7 +803,9 @@ function validateBlocks(
|
|
|
696
803
|
);
|
|
697
804
|
continue;
|
|
698
805
|
}
|
|
699
|
-
const block = field.blocks.
|
|
806
|
+
const block = resolveBlockTypes(field.blocks).find(
|
|
807
|
+
(candidate) => candidate.slug === row.blockType
|
|
808
|
+
);
|
|
700
809
|
if (block === undefined) {
|
|
701
810
|
issues.push(
|
|
702
811
|
issue(
|
|
@@ -748,3 +857,60 @@ function translate(
|
|
|
748
857
|
function localizedFieldLabel(field: SchemaField, i18n?: AdminI18n) {
|
|
749
858
|
return i18n?.text(field.admin.label, field.admin.labelTranslations) ?? field.admin.label;
|
|
750
859
|
}
|
|
860
|
+
|
|
861
|
+
/** Unknown stored variants require schema recovery even if a UI edit removed the row. */
|
|
862
|
+
export function unknownBlockIssues(
|
|
863
|
+
fields: readonly SchemaField[],
|
|
864
|
+
values: FormValues,
|
|
865
|
+
i18n?: AdminI18n,
|
|
866
|
+
prefix = ""
|
|
867
|
+
): ValidationIssue[] {
|
|
868
|
+
const issues: ValidationIssue[] = [];
|
|
869
|
+
for (const field of fields) {
|
|
870
|
+
const value = values[field.name];
|
|
871
|
+
const path = joinPath(prefix, field.name);
|
|
872
|
+
if (field.type === "group" && isRecord(value))
|
|
873
|
+
issues.push(...unknownBlockIssues(field.nested?.fields ?? [], value, i18n, path));
|
|
874
|
+
if (field.type === "plugin") {
|
|
875
|
+
const embedded = embeddedOccurrences(field, value, path);
|
|
876
|
+
issues.push(...embedded.issues);
|
|
877
|
+
for (const occurrence of embedded.occurrences)
|
|
878
|
+
issues.push(
|
|
879
|
+
...unknownBlockIssues(occurrence.block.fields, occurrence.payload, i18n, occurrence.path)
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (!Array.isArray(value)) continue;
|
|
883
|
+
if (field.type === "array") {
|
|
884
|
+
value.forEach((row, index) => {
|
|
885
|
+
if (isRecord(row))
|
|
886
|
+
issues.push(
|
|
887
|
+
...unknownBlockIssues(field.nested?.fields ?? [], row, i18n, `${path}.${index}`)
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
} else if (field.type === "blocks") {
|
|
891
|
+
value.forEach((row, index) => {
|
|
892
|
+
const block = isRecord(row)
|
|
893
|
+
? resolveBlockTypes(field.blocks).find((candidate) => candidate.slug === row.blockType)
|
|
894
|
+
: undefined;
|
|
895
|
+
if (block === undefined)
|
|
896
|
+
issues.push(
|
|
897
|
+
issue(
|
|
898
|
+
"unknown_block_schema",
|
|
899
|
+
`${path}.${index}.blockType`,
|
|
900
|
+
translate(
|
|
901
|
+
i18n,
|
|
902
|
+
"errors:unknownBlockRecovery",
|
|
903
|
+
{},
|
|
904
|
+
"Restore the missing block schema or migrate the document before saving."
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
);
|
|
908
|
+
else
|
|
909
|
+
issues.push(
|
|
910
|
+
...unknownBlockIssues(block.fields, row as FormValues, i18n, `${path}.${index}`)
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return issues;
|
|
916
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { AdminClient } from "@admin/core/api/admin-client";
|
|
2
|
+
import type {
|
|
3
|
+
LiveValidationEnvelope,
|
|
4
|
+
LiveValidationRequest,
|
|
5
|
+
ValidationIssue,
|
|
6
|
+
} from "@riducms/protocol";
|
|
7
|
+
import type { FieldLiveValidation } from "@riducms/plugin";
|
|
8
|
+
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
9
|
+
import { indexFieldValues, correlateFormIssues } from "@admin/core/forms/form-issue-correlation";
|
|
10
|
+
import { captureFieldOccurrence } from "@admin/core/forms/field-occurrence";
|
|
11
|
+
|
|
12
|
+
export type LiveValidationTransport = (
|
|
13
|
+
input: LiveValidationRequest,
|
|
14
|
+
signal: AbortSignal
|
|
15
|
+
) => Promise<LiveValidationEnvelope>;
|
|
16
|
+
|
|
17
|
+
type Feedback = {
|
|
18
|
+
path: string;
|
|
19
|
+
status: FieldLiveValidation["status"];
|
|
20
|
+
issues: ValidationIssue[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One form owns advisory requests; save issues never enter this state. */
|
|
24
|
+
export class LiveValidationController {
|
|
25
|
+
#feedback = $state.raw<ReadonlyMap<string, Feedback>>(new Map());
|
|
26
|
+
#active = new Set<string>();
|
|
27
|
+
#unavailableInputs = new Map<
|
|
28
|
+
symbol,
|
|
29
|
+
{ resolve: () => string | undefined; value: string | undefined }
|
|
30
|
+
>();
|
|
31
|
+
#generation = 0;
|
|
32
|
+
#request?: AbortController;
|
|
33
|
+
#timer?: ReturnType<typeof setTimeout>;
|
|
34
|
+
#deadline?: ReturnType<typeof setTimeout>;
|
|
35
|
+
#transport?: LiveValidationTransport;
|
|
36
|
+
|
|
37
|
+
constructor(private form: FormController) {}
|
|
38
|
+
|
|
39
|
+
configure(transport: LiveValidationTransport) {
|
|
40
|
+
this.reset();
|
|
41
|
+
this.#transport = transport;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
request(input: LiveValidationRequest, signal: AbortSignal) {
|
|
45
|
+
if (!this.#transport) throw new Error("Live validation is unavailable in this form.");
|
|
46
|
+
return this.#transport(input, signal);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get available() {
|
|
50
|
+
return this.#transport !== undefined;
|
|
51
|
+
}
|
|
52
|
+
get inputAvailable(): boolean {
|
|
53
|
+
return (
|
|
54
|
+
this.#unavailableInputs.size === 0 &&
|
|
55
|
+
this.form.enclosingLiveInputAvailable &&
|
|
56
|
+
serializableInput(this.form.values) &&
|
|
57
|
+
indexFieldValues(this.form.schemaFields, this.form.values).every(({ schema, value }) => {
|
|
58
|
+
if (schema?.type !== "text-list" && schema?.type !== "number-list") return true;
|
|
59
|
+
if (value === null || value === undefined) return true;
|
|
60
|
+
// Numeric list controls retain unfinished text in the form itself. No
|
|
61
|
+
// activated check may mistake that buffer for a usable typed Root value.
|
|
62
|
+
return (
|
|
63
|
+
Array.isArray(value) &&
|
|
64
|
+
Array.from(value).every((item) =>
|
|
65
|
+
schema.type === "text-list"
|
|
66
|
+
? typeof item === "string"
|
|
67
|
+
: typeof item === "number" && Number.isFinite(item)
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A control's unfinished buffer is not its last successfully decoded value. */
|
|
75
|
+
inputUnavailable(path: string) {
|
|
76
|
+
let resolve: () => string | undefined;
|
|
77
|
+
try {
|
|
78
|
+
resolve = captureFieldOccurrence(this.form, path).resolve;
|
|
79
|
+
} catch {
|
|
80
|
+
return () => false;
|
|
81
|
+
}
|
|
82
|
+
const lease = Symbol("unfinished-input");
|
|
83
|
+
this.#unavailableInputs.set(lease, { resolve, value: JSON.stringify(this.form.get(path)) });
|
|
84
|
+
this.#schedule(path);
|
|
85
|
+
return () => this.#unavailableInputs.delete(lease);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get issues() {
|
|
89
|
+
return [...this.#feedback.values()].flatMap((state) => state.issues);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
forField(path: string): FieldLiveValidation {
|
|
93
|
+
const status =
|
|
94
|
+
[...this.#feedback.values()].find((state) => state.path === path)?.status ?? "idle";
|
|
95
|
+
return { status, retry: () => this.flush(path) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Any edit invalidates activated checks: their Go code can read any sibling/root value. */
|
|
99
|
+
changed(path?: string) {
|
|
100
|
+
for (const [lease, unavailable] of this.#unavailableInputs) {
|
|
101
|
+
const current = unavailable.resolve();
|
|
102
|
+
// An ancestor write can merely reorder a still-mounted unfinished control.
|
|
103
|
+
// Release only a removed occurrence or a real replacement of its value.
|
|
104
|
+
if (
|
|
105
|
+
current === undefined ||
|
|
106
|
+
path === current ||
|
|
107
|
+
JSON.stringify(this.form.get(current)) !== unavailable.value
|
|
108
|
+
)
|
|
109
|
+
this.#unavailableInputs.delete(lease);
|
|
110
|
+
}
|
|
111
|
+
this.#schedule(path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#schedule(path?: string) {
|
|
115
|
+
if (!this.available || this.form.submitting || !this.form.editorScopeActive) return;
|
|
116
|
+
this.#cancel();
|
|
117
|
+
const locations = this.#locations();
|
|
118
|
+
const current = new Set(locations.map((location) => location.token));
|
|
119
|
+
this.#active = new Set([...this.#active].filter((token) => current.has(token)));
|
|
120
|
+
if (path !== undefined)
|
|
121
|
+
for (const location of locations)
|
|
122
|
+
if (related(path, location.path)) this.#active.add(location.token);
|
|
123
|
+
const status = this.inputAvailable ? "pending" : "skipped";
|
|
124
|
+
this.#feedback = new Map(
|
|
125
|
+
locations
|
|
126
|
+
.filter(({ token }) => this.#active.has(token))
|
|
127
|
+
.map(({ token, path }) => [token, { path, status, issues: [] }])
|
|
128
|
+
);
|
|
129
|
+
if (this.#active.size && status === "pending") this.#timer = setTimeout(() => this.#run(), 500);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
flush(path: string) {
|
|
133
|
+
if (
|
|
134
|
+
[...this.#feedback.values()].some(
|
|
135
|
+
(state) =>
|
|
136
|
+
(state.status === "pending" || state.status === "failed") && related(state.path, path)
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
this.#run();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Saving cancels advisory work but retains activation for later user edits. */
|
|
143
|
+
suspend() {
|
|
144
|
+
this.#cancel();
|
|
145
|
+
this.#feedback = new Map();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
reset() {
|
|
149
|
+
this.suspend();
|
|
150
|
+
this.#active.clear();
|
|
151
|
+
this.#unavailableInputs.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Binding revocation can be discovered during a derived read. Abort synchronously. */
|
|
155
|
+
dispose() {
|
|
156
|
+
this.#cancel();
|
|
157
|
+
this.#active.clear();
|
|
158
|
+
const generation = this.#generation;
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
if (generation === this.#generation) this.#feedback = new Map();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#cancel() {
|
|
165
|
+
this.#generation += 1;
|
|
166
|
+
this.#request?.abort();
|
|
167
|
+
this.#request = undefined;
|
|
168
|
+
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
|
169
|
+
this.#timer = undefined;
|
|
170
|
+
if (this.#deadline !== undefined) clearTimeout(this.#deadline);
|
|
171
|
+
this.#deadline = undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#locations() {
|
|
175
|
+
return indexFieldValues(this.form.schemaFields, this.form.values).filter(
|
|
176
|
+
({ schema, path }) =>
|
|
177
|
+
schema?.liveValidation &&
|
|
178
|
+
!schema.admin.readOnly &&
|
|
179
|
+
this.form.includesLiveValidation(path, schema.path)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async #run() {
|
|
184
|
+
if (
|
|
185
|
+
!this.available ||
|
|
186
|
+
this.form.submitting ||
|
|
187
|
+
!this.form.editorScopeActive ||
|
|
188
|
+
!this.inputAvailable
|
|
189
|
+
)
|
|
190
|
+
return;
|
|
191
|
+
this.#cancel();
|
|
192
|
+
const generation = this.#generation;
|
|
193
|
+
const request = new AbortController();
|
|
194
|
+
this.#request = request;
|
|
195
|
+
const locations = this.#locations().filter(({ token }) => this.#active.has(token));
|
|
196
|
+
if (!locations.length) return;
|
|
197
|
+
this.#feedback = new Map(
|
|
198
|
+
locations.map(({ token, path }) => [token, { path, status: "pending", issues: [] }])
|
|
199
|
+
);
|
|
200
|
+
const current = () =>
|
|
201
|
+
generation === this.#generation &&
|
|
202
|
+
!request.signal.aborted &&
|
|
203
|
+
!this.form.submitting &&
|
|
204
|
+
this.form.editorScopeActive;
|
|
205
|
+
try {
|
|
206
|
+
const values = this.form.snapshot();
|
|
207
|
+
let data: LiveValidationRequest["data"];
|
|
208
|
+
try {
|
|
209
|
+
data = this.form.liveValidationData();
|
|
210
|
+
} catch {
|
|
211
|
+
// A malformed plugin envelope has no usable submitted representation yet.
|
|
212
|
+
this.#feedback = new Map(
|
|
213
|
+
[...this.#feedback].map(([token, state]) => [
|
|
214
|
+
token,
|
|
215
|
+
{ ...state, status: "skipped", issues: [] },
|
|
216
|
+
])
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Keep every request within the server's bounded field count, including large forms.
|
|
221
|
+
for (let offset = 0; offset < locations.length; offset += 64) {
|
|
222
|
+
const batch = locations.slice(offset, offset + 64);
|
|
223
|
+
// Leave room above the server's five-second evaluation bound for transport.
|
|
224
|
+
this.#deadline = setTimeout(() => {
|
|
225
|
+
if (!current()) return;
|
|
226
|
+
this.#cancel();
|
|
227
|
+
this.#failPending();
|
|
228
|
+
}, 8_000);
|
|
229
|
+
const result = await this.request(
|
|
230
|
+
{
|
|
231
|
+
...(this.form.resource?.id === undefined ? {} : { id: this.form.resource.id }),
|
|
232
|
+
data,
|
|
233
|
+
fields: batch.map(({ path }) => path),
|
|
234
|
+
},
|
|
235
|
+
request.signal
|
|
236
|
+
);
|
|
237
|
+
if (!current()) return;
|
|
238
|
+
if (this.#deadline !== undefined) clearTimeout(this.#deadline);
|
|
239
|
+
this.#deadline = undefined;
|
|
240
|
+
const feedback = new Map(this.#feedback);
|
|
241
|
+
for (const location of batch) {
|
|
242
|
+
const evaluation = result.evaluations.find((item) => item.path === location.path);
|
|
243
|
+
// A missing evaluation cannot silently become a successful check.
|
|
244
|
+
feedback.set(location.token, {
|
|
245
|
+
path: location.path,
|
|
246
|
+
status: evaluation?.status ?? "failed",
|
|
247
|
+
issues:
|
|
248
|
+
evaluation === undefined
|
|
249
|
+
? []
|
|
250
|
+
: correlateFormIssues(
|
|
251
|
+
this.form.schemaFields,
|
|
252
|
+
values,
|
|
253
|
+
this.form.values,
|
|
254
|
+
evaluation.issues
|
|
255
|
+
),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
this.#feedback = feedback;
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
if (!current()) return;
|
|
262
|
+
this.#failPending();
|
|
263
|
+
} finally {
|
|
264
|
+
if (this.#request === request) {
|
|
265
|
+
this.#request = undefined;
|
|
266
|
+
if (this.#deadline !== undefined) clearTimeout(this.#deadline);
|
|
267
|
+
this.#deadline = undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#failPending() {
|
|
273
|
+
this.#feedback = new Map(
|
|
274
|
+
[...this.#feedback].map(([token, state]) => [
|
|
275
|
+
token,
|
|
276
|
+
state.status === "pending" ? { ...state, status: "failed", issues: [] } : state,
|
|
277
|
+
])
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function related(left: string, right: string) {
|
|
283
|
+
return left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** JSON would coerce NaN/Infinity to null, incorrectly making malformed input look empty. */
|
|
287
|
+
function serializableInput(value: unknown, seen = new Set<object>(), depth = 0): boolean {
|
|
288
|
+
if (depth > 100) return false;
|
|
289
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
290
|
+
if (
|
|
291
|
+
value === null ||
|
|
292
|
+
value === undefined ||
|
|
293
|
+
typeof value === "string" ||
|
|
294
|
+
typeof value === "boolean"
|
|
295
|
+
)
|
|
296
|
+
return true;
|
|
297
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
298
|
+
seen.add(value);
|
|
299
|
+
const available = Object.values(value).every((child) =>
|
|
300
|
+
serializableInput(child, seen, depth + 1)
|
|
301
|
+
);
|
|
302
|
+
seen.delete(value);
|
|
303
|
+
return available;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Connect a document form to the same authenticated SDK used for its writes. */
|
|
307
|
+
export function connectDocumentLiveValidation(form: FormController, client: AdminClient) {
|
|
308
|
+
form.configureLiveValidation((input, signal) => {
|
|
309
|
+
const resource = form.resource;
|
|
310
|
+
if (!resource) throw new Error("No document is open for live validation.");
|
|
311
|
+
const options = { signal, locale: form.contentLocale };
|
|
312
|
+
const { id: _id, ...globalInput } = input;
|
|
313
|
+
return resource.global
|
|
314
|
+
? client.globalLiveValidation(resource.collection, globalInput, options)
|
|
315
|
+
: client.collectionLiveValidation(resource.collection, input, options);
|
|
316
|
+
});
|
|
317
|
+
}
|