@xelqun/forms 0.1.0 → 0.2.0
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/README.md +46 -61
- package/package.json +32 -2
- package/resources/css/xelqun.css +121 -0
- package/resources/js/components/XqAction.vue +143 -0
- package/resources/js/components/XqFieldSlot.vue +20 -0
- package/resources/js/components/XqFieldWrapper.vue +74 -30
- package/resources/js/components/XqForm.vue +36 -9
- package/resources/js/components/XqNode.vue +8 -1
- package/resources/js/components/XqUnknownNode.vue +2 -1
- package/resources/js/components/defaults.ts +36 -0
- package/resources/js/components/fields/XqBlockPicker.vue +68 -0
- package/resources/js/components/fields/XqCheckboxField.vue +35 -9
- package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
- package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
- package/resources/js/components/fields/XqColorPickerField.vue +156 -0
- package/resources/js/components/fields/XqDatePickerField.vue +149 -53
- package/resources/js/components/fields/XqFileUploadField.vue +524 -165
- package/resources/js/components/fields/XqImageEditor.vue +165 -0
- package/resources/js/components/fields/XqKeyValueField.vue +168 -0
- package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
- package/resources/js/components/fields/XqRadioField.vue +9 -5
- package/resources/js/components/fields/XqRepeaterField.vue +314 -79
- package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
- package/resources/js/components/fields/XqRichEditorField.vue +280 -0
- package/resources/js/components/fields/XqSelectField.vue +29 -12
- package/resources/js/components/fields/XqSliderField.vue +47 -0
- package/resources/js/components/fields/XqTagsInputField.vue +127 -0
- package/resources/js/components/fields/XqTextInputField.vue +121 -12
- package/resources/js/components/fields/XqTextareaField.vue +39 -16
- package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
- package/resources/js/components/fields/XqToggleField.vue +33 -9
- package/resources/js/components/layouts/XqActions.vue +19 -0
- package/resources/js/components/layouts/XqBlock.vue +52 -0
- package/resources/js/components/layouts/XqCallout.vue +46 -0
- package/resources/js/components/layouts/XqEmptyState.vue +37 -0
- package/resources/js/components/layouts/XqFlex.vue +23 -0
- package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
- package/resources/js/components/layouts/XqIconNode.vue +32 -0
- package/resources/js/components/layouts/XqImageNode.vue +30 -0
- package/resources/js/components/layouts/XqSection.vue +3 -1
- package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
- package/resources/js/components/layouts/XqTextNode.vue +49 -0
- package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
- package/resources/js/components/layouts/XqWizard.vue +168 -0
- package/resources/js/composables/context.ts +14 -0
- package/resources/js/composables/useField.ts +21 -3
- package/resources/js/composables/usePanelErrors.ts +37 -0
- package/resources/js/composables/useSortable.ts +70 -0
- package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
- package/resources/js/core/dates.ts +38 -0
- package/resources/js/core/images.ts +110 -0
- package/resources/js/core/log.ts +1 -1
- package/resources/js/core/markdown.ts +61 -0
- package/resources/js/core/mask.ts +42 -0
- package/resources/js/core/patch.ts +6 -3
- package/resources/js/core/paths.ts +11 -14
- package/resources/js/core/store.ts +515 -25
- package/resources/js/core/translate.ts +17 -0
- package/resources/js/core/validation.ts +126 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/lib/utils.ts +15 -0
- package/resources/js/plugin.ts +103 -6
- package/resources/js/schema/types.ts +102 -0
- package/resources/js/testing.ts +50 -0
- package/resources/js/ui/XqCalendar.vue +4 -0
- package/resources/js/ui/XqCombobox.vue +89 -32
- package/resources/js/ui/XqConfirmDialog.vue +58 -0
- package/resources/js/ui/XqDialog.vue +46 -0
- package/resources/js/ui/XqIcon.vue +28 -0
- package/resources/js/ui/XqSelect.vue +38 -18
- package/resources/js/ui/XqTabs.vue +1 -1
- package/resources/js/ui/XqTimeField.vue +66 -0
- package/resources/js/ui/classes.ts +33 -0
- package/resources/js/ui/icons.ts +95 -0
- package/resources/js/ui/index.ts +5 -0
- package/resources/js/ui/options.ts +46 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { router } from '@inertiajs/vue3';
|
|
2
|
-
import { effectScope, nextTick, reactive, ref, watch } from 'vue';
|
|
2
|
+
import { effectScope, nextTick, reactive, ref, shallowReactive, watch } from 'vue';
|
|
3
3
|
import type { Ref } from 'vue';
|
|
4
4
|
import type {
|
|
5
|
+
ActionRequest,
|
|
6
|
+
ActionResult,
|
|
7
|
+
BlockOption,
|
|
5
8
|
ConditionNode,
|
|
6
9
|
FormSchema,
|
|
7
10
|
LiveConfig,
|
|
8
11
|
SchemaNode,
|
|
9
12
|
SelectOption,
|
|
13
|
+
TextKey,
|
|
10
14
|
UpdatePatch,
|
|
11
15
|
UpdateRequest,
|
|
12
16
|
UploadResult,
|
|
17
|
+
ValidateRequest,
|
|
13
18
|
} from '../schema/types';
|
|
14
19
|
import { SUPPORTED_SCHEMA_VERSIONS } from '../schema/types';
|
|
15
20
|
import { evaluateCondition } from './conditions';
|
|
@@ -20,10 +25,19 @@ import { clone, deletePath, getPath, overlaps, replaceRow, setPath, startsWithPa
|
|
|
20
25
|
import { RequestLog } from './log';
|
|
21
26
|
import { RequestQueue } from './queue';
|
|
22
27
|
import { newRowKey } from './rows';
|
|
28
|
+
import { translate } from './translate';
|
|
29
|
+
import type { Replacements } from './translate';
|
|
30
|
+
import { firstFailure } from './validation';
|
|
23
31
|
import { isPlainObject } from './values';
|
|
24
32
|
|
|
25
33
|
export type SetSource = 'user' | 'server' | 'computed';
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How long a request runs before its spinner appears, so fast ones never flash.
|
|
37
|
+
* The same 200ms as Livewire's wire:loading.delay.
|
|
38
|
+
*/
|
|
39
|
+
export const LOADING_INDICATOR_DELAY = 200;
|
|
40
|
+
|
|
27
41
|
export interface SubmitOptions {
|
|
28
42
|
method?: 'post' | 'put' | 'patch';
|
|
29
43
|
data?: Record<string, unknown>;
|
|
@@ -33,6 +47,23 @@ export interface SubmitOptions {
|
|
|
33
47
|
onFinish?: () => void;
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
/** A multi-step layout. While it has steps left, submitting moves to the next one. */
|
|
51
|
+
export interface Stepper {
|
|
52
|
+
hasNext: () => boolean;
|
|
53
|
+
next: () => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The part of a modal's form store its action needs. */
|
|
57
|
+
export interface ActionModal {
|
|
58
|
+
state: { data: Record<string, any> };
|
|
59
|
+
settled: () => Promise<void>;
|
|
60
|
+
validate: () => boolean;
|
|
61
|
+
addErrors: (incoming: Record<string, string | string[]>) => void;
|
|
62
|
+
focusFirstError: () => void;
|
|
63
|
+
fatalError: Ref<string | null>;
|
|
64
|
+
liveError: Ref<string | null>;
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
export interface FormStoreOptions {
|
|
37
68
|
send?: (url: string, request: UpdateRequest, signal: AbortSignal) => Promise<UpdatePatch>;
|
|
38
69
|
}
|
|
@@ -80,6 +111,24 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
80
111
|
const root: Ref<HTMLElement | null> = ref(null);
|
|
81
112
|
const uploads = new Set<Promise<unknown>>();
|
|
82
113
|
const uploading = ref(0);
|
|
114
|
+
const steppers = shallowReactive(new Set<Stepper>());
|
|
115
|
+
const validating = ref(false);
|
|
116
|
+
let cleanState: string | null = null;
|
|
117
|
+
|
|
118
|
+
function t(text: string, replacements?: Replacements): string {
|
|
119
|
+
return translate(schema.messages, text, replacements);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Treat the current values as saved.
|
|
124
|
+
*/
|
|
125
|
+
function markClean(): void {
|
|
126
|
+
cleanState = JSON.stringify(state.data);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isDirty(): boolean {
|
|
130
|
+
return cleanState !== null && JSON.stringify(state.data) !== cleanState;
|
|
131
|
+
}
|
|
83
132
|
|
|
84
133
|
function get(path: string): any {
|
|
85
134
|
return getPath(state, path);
|
|
@@ -157,6 +206,19 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
157
206
|
return !!node.disabled || condition(node.rules?.disabled, false);
|
|
158
207
|
}
|
|
159
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The label, hint or placeholder to show, following any browser-side condition.
|
|
211
|
+
*/
|
|
212
|
+
function text(node: SchemaNode, key: TextKey): string | null {
|
|
213
|
+
const rule = node.rules?.text?.[key];
|
|
214
|
+
|
|
215
|
+
if (!rule) {
|
|
216
|
+
return node[key] ?? null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return evaluateCondition(rule.when, get) ? rule.text : rule.otherwise;
|
|
220
|
+
}
|
|
221
|
+
|
|
160
222
|
function index(list: SchemaNode[]): void {
|
|
161
223
|
eachNode(list, (node) => {
|
|
162
224
|
nodes.set(node.key, node);
|
|
@@ -179,7 +241,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
179
241
|
});
|
|
180
242
|
}
|
|
181
243
|
|
|
182
|
-
if (node.
|
|
244
|
+
if (node.containsRows && node.path && !effects.has(`${node.key}::rows`)) {
|
|
183
245
|
const path = node.path;
|
|
184
246
|
|
|
185
247
|
scope.run(() => {
|
|
@@ -217,6 +279,19 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
217
279
|
return node.props.rows;
|
|
218
280
|
}
|
|
219
281
|
|
|
282
|
+
function blockOf(node: SchemaNode, name: unknown): BlockOption | undefined {
|
|
283
|
+
return node.props?.blocks?.find((block: BlockOption) => block.name === name);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A builder's row is built from its block, a repeater's from one template.
|
|
288
|
+
*/
|
|
289
|
+
function rowTemplate(node: SchemaNode, key: string): SchemaNode[] {
|
|
290
|
+
return (
|
|
291
|
+
blockOf(node, get(`${node.path}.${key}.type`))?.template ?? node.props?.template ?? []
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
220
295
|
/**
|
|
221
296
|
* Make the repeater's row nodes match the row keys in state.
|
|
222
297
|
*/
|
|
@@ -225,7 +300,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
225
300
|
|
|
226
301
|
for (const key of keys) {
|
|
227
302
|
if (!rows[key]) {
|
|
228
|
-
rows[key] = replaceRow(node
|
|
303
|
+
rows[key] = replaceRow(rowTemplate(node, key), key, node.props?.rowPlaceholder);
|
|
229
304
|
index(rows[key]);
|
|
230
305
|
}
|
|
231
306
|
}
|
|
@@ -251,19 +326,96 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
251
326
|
notify(path, 'user');
|
|
252
327
|
}
|
|
253
328
|
|
|
254
|
-
function addRow(node: SchemaNode): string {
|
|
329
|
+
function addRow(node: SchemaNode, block?: string): string {
|
|
255
330
|
const key = newRowKey();
|
|
256
331
|
|
|
257
332
|
if (!isPlainObject(get(node.path!))) {
|
|
258
333
|
setPath(state, node.path!, {});
|
|
259
334
|
}
|
|
260
335
|
|
|
261
|
-
setPath(
|
|
336
|
+
setPath(
|
|
337
|
+
state,
|
|
338
|
+
`${node.path}.${key}`,
|
|
339
|
+
clone(blockOf(node, block)?.newRow ?? node.props?.newRow ?? {}),
|
|
340
|
+
);
|
|
262
341
|
touchRows(node);
|
|
263
342
|
|
|
264
343
|
return key;
|
|
265
344
|
}
|
|
266
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Fit a copied row and its nodes for life on their own: stored files go, and
|
|
348
|
+
* nested rows get new keys, because theirs name records of the original.
|
|
349
|
+
*/
|
|
350
|
+
function resetCopiedRow(copy: Record<string, any>, nodes: SchemaNode[], rowPath: string): void {
|
|
351
|
+
const relative = (path: string) => path.slice(rowPath.length + 1);
|
|
352
|
+
|
|
353
|
+
for (const node of nodes) {
|
|
354
|
+
if (node.acceptsUploads && node.path) {
|
|
355
|
+
setPath(copy, relative(node.path), null);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (node.containsRows && node.path) {
|
|
359
|
+
const inner = rowsOf(node);
|
|
360
|
+
const fresh: Record<string, unknown> = {};
|
|
361
|
+
|
|
362
|
+
for (const [key, row] of Object.entries(getPath(copy, relative(node.path)) ?? {})) {
|
|
363
|
+
const freshKey = newRowKey();
|
|
364
|
+
const freshPath = `${node.path}.${freshKey}`;
|
|
365
|
+
|
|
366
|
+
inner[freshKey] = replaceRow(
|
|
367
|
+
inner[key] ?? [],
|
|
368
|
+
freshPath,
|
|
369
|
+
`${node.path}.${key}`,
|
|
370
|
+
);
|
|
371
|
+
resetCopiedRow(row as Record<string, any>, inner[freshKey], freshPath);
|
|
372
|
+
fresh[freshKey] = row;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
setPath(copy, relative(node.path), fresh);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
resetCopiedRow(copy, node.children ?? [], rowPath);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Add a copy of a row right after it, and return the copy's key. Its nodes come
|
|
384
|
+
* from the original, not the template, which has no options loaded for a row.
|
|
385
|
+
*/
|
|
386
|
+
function cloneRow(node: SchemaNode, key: string): string {
|
|
387
|
+
const copyKey = newRowKey();
|
|
388
|
+
const copyPath = `${node.path}.${copyKey}`;
|
|
389
|
+
const rows = get(node.path!) ?? {};
|
|
390
|
+
const copy = clone(rows[key]);
|
|
391
|
+
const copyNodes = replaceRow(rowsOf(node)[key] ?? [], copyPath, `${node.path}.${key}`);
|
|
392
|
+
|
|
393
|
+
resetCopiedRow(copy, copyNodes, copyPath);
|
|
394
|
+
|
|
395
|
+
const reordered: Record<string, unknown> = {};
|
|
396
|
+
|
|
397
|
+
for (const [rowKey, row] of Object.entries(rows)) {
|
|
398
|
+
reordered[rowKey] = row;
|
|
399
|
+
|
|
400
|
+
if (rowKey === key) {
|
|
401
|
+
reordered[copyKey] = copy;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
setPath(state, node.path!, reordered);
|
|
406
|
+
rowsOf(node)[copyKey] = copyNodes;
|
|
407
|
+
index(copyNodes);
|
|
408
|
+
touchRows(node);
|
|
409
|
+
|
|
410
|
+
const rowNodes = rowsOf(node);
|
|
411
|
+
|
|
412
|
+
node.props!.rows = Object.fromEntries(
|
|
413
|
+
Object.keys(reordered).map((rowKey) => [rowKey, rowNodes[rowKey]]),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
return copyKey;
|
|
417
|
+
}
|
|
418
|
+
|
|
267
419
|
function removeRow(node: SchemaNode, key: string): void {
|
|
268
420
|
const prefix = `${node.path}.${key}`;
|
|
269
421
|
|
|
@@ -289,6 +441,10 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
289
441
|
const reordered: Record<string, unknown> = {};
|
|
290
442
|
keys.forEach((rowKey) => (reordered[rowKey] = rows[rowKey]));
|
|
291
443
|
setPath(state, node.path!, reordered);
|
|
444
|
+
|
|
445
|
+
const rowNodes = rowsOf(node);
|
|
446
|
+
|
|
447
|
+
node.props!.rows = Object.fromEntries(keys.map((rowKey) => [rowKey, rowNodes[rowKey]]));
|
|
292
448
|
touchRows(node);
|
|
293
449
|
}
|
|
294
450
|
|
|
@@ -311,7 +467,10 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
311
467
|
}
|
|
312
468
|
|
|
313
469
|
for (const [path, messages] of Object.entries(patch.errors ?? {})) {
|
|
314
|
-
|
|
470
|
+
// The user has already changed this value, so the message is about an old one.
|
|
471
|
+
if ((editVersions.get(path) ?? 0) === (versions.get(path) ?? 0)) {
|
|
472
|
+
errors[path] = Array.isArray(messages) ? messages[0] : messages;
|
|
473
|
+
}
|
|
315
474
|
}
|
|
316
475
|
}
|
|
317
476
|
|
|
@@ -350,28 +509,71 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
350
509
|
},
|
|
351
510
|
onError: (error) => {
|
|
352
511
|
if (error instanceof HttpError && error.status === 419) {
|
|
353
|
-
fatalError.value = 'This form has expired. Reload the page to continue.';
|
|
512
|
+
fatalError.value = t('This form has expired. Reload the page to continue.');
|
|
354
513
|
} else {
|
|
355
|
-
liveError.value =
|
|
356
|
-
'Some fields could not be updated. Check your connection and try again.'
|
|
514
|
+
liveError.value = t(
|
|
515
|
+
'Some fields could not be updated. Check your connection and try again.',
|
|
516
|
+
);
|
|
357
517
|
}
|
|
358
518
|
|
|
359
519
|
console.error('[xelqun] Live update failed.', error);
|
|
360
520
|
},
|
|
361
521
|
});
|
|
362
522
|
|
|
523
|
+
const indicatorPaths = ref<string[]>([]);
|
|
524
|
+
let indicatorTimer: ReturnType<typeof setTimeout> | null = null;
|
|
525
|
+
|
|
526
|
+
function clearIndicatorTimer(): void {
|
|
527
|
+
if (indicatorTimer !== null) {
|
|
528
|
+
clearTimeout(indicatorTimer);
|
|
529
|
+
indicatorTimer = null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
scope.run(() => {
|
|
534
|
+
watch(
|
|
535
|
+
queue.loadingPaths,
|
|
536
|
+
(paths) => {
|
|
537
|
+
if (paths.length === 0) {
|
|
538
|
+
clearIndicatorTimer();
|
|
539
|
+
indicatorPaths.value = [];
|
|
540
|
+
} else if (indicatorPaths.value.length > 0) {
|
|
541
|
+
indicatorPaths.value = paths;
|
|
542
|
+
} else if (indicatorTimer === null) {
|
|
543
|
+
indicatorTimer = setTimeout(() => {
|
|
544
|
+
indicatorTimer = null;
|
|
545
|
+
indicatorPaths.value = queue.loadingPaths.value;
|
|
546
|
+
}, LOADING_INDICATOR_DELAY);
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
{ flush: 'sync' },
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
function dependsOnAny(node: SchemaNode, paths: string[]): boolean {
|
|
554
|
+
return !!node.deps?.some((dependency) => paths.some((path) => overlaps(dependency, path)));
|
|
555
|
+
}
|
|
556
|
+
|
|
363
557
|
/**
|
|
364
558
|
* True while a request that can change this node is in flight.
|
|
365
559
|
*/
|
|
366
560
|
function isLoading(node: SchemaNode): boolean {
|
|
367
|
-
|
|
561
|
+
return dependsOnAny(node, queue.loadingPaths.value);
|
|
562
|
+
}
|
|
368
563
|
|
|
369
|
-
|
|
564
|
+
/**
|
|
565
|
+
* True once that request has run long enough to deserve a spinner.
|
|
566
|
+
*/
|
|
567
|
+
function showsLoadingIndicator(node: SchemaNode): boolean {
|
|
568
|
+
return dependsOnAny(node, indicatorPaths.value);
|
|
370
569
|
}
|
|
371
570
|
|
|
372
571
|
function setErrors(incoming: Record<string, string | string[]>): void {
|
|
373
572
|
clearErrors();
|
|
573
|
+
addErrors(incoming);
|
|
574
|
+
}
|
|
374
575
|
|
|
576
|
+
function addErrors(incoming: Record<string, string | string[]>): void {
|
|
375
577
|
for (const [path, messages] of Object.entries(incoming ?? {})) {
|
|
376
578
|
errors[path] = Array.isArray(messages) ? messages[0] : messages;
|
|
377
579
|
}
|
|
@@ -381,6 +583,253 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
381
583
|
Object.keys(errors).forEach((path) => delete errors[path]);
|
|
382
584
|
}
|
|
383
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Wait until the state is what the user meant to send: nothing uploading,
|
|
588
|
+
* and no live update on its way.
|
|
589
|
+
*/
|
|
590
|
+
async function settled(): Promise<void> {
|
|
591
|
+
await Promise.allSettled(uploads);
|
|
592
|
+
await queue.idle();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Run the rules the browser knows, and show what they find.
|
|
597
|
+
*/
|
|
598
|
+
function validate(): boolean {
|
|
599
|
+
const found = findClientErrors(components);
|
|
600
|
+
|
|
601
|
+
if (Object.keys(found).length === 0) {
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
setErrors(found);
|
|
606
|
+
focusFirstError();
|
|
607
|
+
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Forget the errors of these fields and the rows inside them, and no others.
|
|
613
|
+
*/
|
|
614
|
+
function clearErrorsIn(paths: string[]): void {
|
|
615
|
+
Object.keys(errors)
|
|
616
|
+
.filter((path) => paths.some((scope) => startsWithPath(path, scope)))
|
|
617
|
+
.forEach((path) => delete errors[path]);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Run the rules the server sent for the browser, the way the server would:
|
|
622
|
+
* nothing under a hidden node, and nothing on a field the user can't change.
|
|
623
|
+
*/
|
|
624
|
+
function findClientErrors(
|
|
625
|
+
list: SchemaNode[],
|
|
626
|
+
rowLabel: string | null = null,
|
|
627
|
+
found: Record<string, string> = {},
|
|
628
|
+
): Record<string, string> {
|
|
629
|
+
for (const node of list) {
|
|
630
|
+
if (!isVisible(node)) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const checked = node.path && node.validation && !node.computed && !node.readOnly;
|
|
635
|
+
|
|
636
|
+
if (checked && !isDisabled(node)) {
|
|
637
|
+
const message = firstFailure(node.validation!, get(node.path!), isRequired(node));
|
|
638
|
+
|
|
639
|
+
if (message) {
|
|
640
|
+
const name = node.validation!.attribute;
|
|
641
|
+
const attribute = rowLabel ? `${name} (${rowLabel})` : name;
|
|
642
|
+
|
|
643
|
+
found[node.path!] = translate(undefined, message, {
|
|
644
|
+
attribute,
|
|
645
|
+
Attribute: attribute.charAt(0).toUpperCase() + attribute.slice(1),
|
|
646
|
+
ATTRIBUTE: attribute.toUpperCase(),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
findClientErrors(node.children ?? [], rowLabel, found);
|
|
652
|
+
|
|
653
|
+
if (node.containsRows) {
|
|
654
|
+
rowKeys(node).forEach((key, index) => {
|
|
655
|
+
const label = `${node.props?.itemLabel} ${index + 1}`;
|
|
656
|
+
|
|
657
|
+
findClientErrors(
|
|
658
|
+
rowsOf(node)[key] ?? [],
|
|
659
|
+
rowLabel ? `${rowLabel}, ${label}` : label,
|
|
660
|
+
found,
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return found;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Show why a request failed: on the fields a validation error names, in
|
|
671
|
+
* the target form, or as a message on this one.
|
|
672
|
+
*/
|
|
673
|
+
function reportFailure(
|
|
674
|
+
error: unknown,
|
|
675
|
+
target: Omit<ActionModal, 'state' | 'settled' | 'validate'> = {
|
|
676
|
+
addErrors,
|
|
677
|
+
focusFirstError,
|
|
678
|
+
fatalError,
|
|
679
|
+
liveError,
|
|
680
|
+
},
|
|
681
|
+
): void {
|
|
682
|
+
if (error instanceof HttpError && error.status === 422) {
|
|
683
|
+
target.addErrors(error.validationErrors);
|
|
684
|
+
target.focusFirstError();
|
|
685
|
+
} else if (error instanceof HttpError && error.status === 419) {
|
|
686
|
+
target.fatalError.value = t('This form has expired. Reload the page to continue.');
|
|
687
|
+
} else {
|
|
688
|
+
target.liveError.value = t(
|
|
689
|
+
'Some fields could not be updated. Check your connection and try again.',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function actionBody(
|
|
695
|
+
node: SchemaNode,
|
|
696
|
+
phase: ActionRequest['phase'],
|
|
697
|
+
data?: Record<string, any>,
|
|
698
|
+
) {
|
|
699
|
+
return {
|
|
700
|
+
token: schema.token ?? '',
|
|
701
|
+
action: node.key,
|
|
702
|
+
phase,
|
|
703
|
+
state: { data: clone(state.data) },
|
|
704
|
+
data,
|
|
705
|
+
deps: dependencyMap(),
|
|
706
|
+
} satisfies ActionRequest;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Ask the server for the form of an action's modal. Null means it failed,
|
|
711
|
+
* and the failure is already on screen.
|
|
712
|
+
*/
|
|
713
|
+
async function mountAction(node: SchemaNode): Promise<FormSchema | null> {
|
|
714
|
+
await settled();
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
const response = await requests.track(
|
|
718
|
+
'action',
|
|
719
|
+
[],
|
|
720
|
+
`${node.props?.label} (open)`,
|
|
721
|
+
postJson<{ form: FormSchema | null }>(
|
|
722
|
+
schema.endpoints.action,
|
|
723
|
+
actionBody(node, 'mount'),
|
|
724
|
+
),
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
return response.form;
|
|
728
|
+
} catch (error) {
|
|
729
|
+
reportFailure(error);
|
|
730
|
+
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Run an action and apply what it changed. Null means it didn't go through,
|
|
737
|
+
* so a modal knows to stay open and show its errors.
|
|
738
|
+
*/
|
|
739
|
+
async function runAction(node: SchemaNode, modal?: ActionModal): Promise<ActionResult | null> {
|
|
740
|
+
await settled();
|
|
741
|
+
await modal?.settled();
|
|
742
|
+
|
|
743
|
+
if (modal && !modal.validate()) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const versions = new Map(editVersions);
|
|
748
|
+
|
|
749
|
+
liveError.value = null;
|
|
750
|
+
|
|
751
|
+
if (modal) {
|
|
752
|
+
modal.liveError.value = null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
const response = await requests.track(
|
|
757
|
+
'action',
|
|
758
|
+
[],
|
|
759
|
+
node.props?.label ?? null,
|
|
760
|
+
postJson<ActionResult>(
|
|
761
|
+
schema.endpoints.action,
|
|
762
|
+
actionBody(node, 'run', modal ? clone(modal.state.data) : undefined),
|
|
763
|
+
),
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
applyPatch(response, versions);
|
|
767
|
+
|
|
768
|
+
return response;
|
|
769
|
+
} catch (error) {
|
|
770
|
+
reportFailure(error, modal);
|
|
771
|
+
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Check one step, in the browser and then on the server, and say whether it
|
|
778
|
+
* passed. Its errors are replaced, and the rest of the form's stay.
|
|
779
|
+
*/
|
|
780
|
+
async function validateStep(step: SchemaNode): Promise<boolean> {
|
|
781
|
+
const paths: string[] = [];
|
|
782
|
+
|
|
783
|
+
eachNode([step], (node) => node.path && paths.push(node.path));
|
|
784
|
+
validating.value = true;
|
|
785
|
+
liveError.value = null;
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
await settled();
|
|
789
|
+
|
|
790
|
+
const found = findClientErrors([step]);
|
|
791
|
+
|
|
792
|
+
clearErrorsIn(paths);
|
|
793
|
+
|
|
794
|
+
if (Object.keys(found).length > 0) {
|
|
795
|
+
Object.assign(errors, found);
|
|
796
|
+
focusFirstError();
|
|
797
|
+
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const versions = new Map(editVersions);
|
|
802
|
+
const body: ValidateRequest = {
|
|
803
|
+
token: schema.token ?? '',
|
|
804
|
+
step: step.key,
|
|
805
|
+
state: { data: clone(state.data) },
|
|
806
|
+
deps: dependencyMap(),
|
|
807
|
+
};
|
|
808
|
+
const patch = await requests.track(
|
|
809
|
+
'validate',
|
|
810
|
+
paths,
|
|
811
|
+
step.label ?? null,
|
|
812
|
+
postJson<UpdatePatch>(schema.endpoints.validate, body),
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
applyPatch(patch, versions);
|
|
816
|
+
|
|
817
|
+
const failed = Object.keys(patch.errors ?? {}).length > 0;
|
|
818
|
+
|
|
819
|
+
if (failed) {
|
|
820
|
+
focusFirstError();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return !failed;
|
|
824
|
+
} catch (error) {
|
|
825
|
+
reportFailure(error);
|
|
826
|
+
|
|
827
|
+
return false;
|
|
828
|
+
} finally {
|
|
829
|
+
validating.value = false;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
384
833
|
/**
|
|
385
834
|
* Ask the UI to reveal and focus the first field with an error, in schema order.
|
|
386
835
|
*/
|
|
@@ -410,11 +859,15 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
410
859
|
const element = root.value?.querySelector<HTMLElement>(
|
|
411
860
|
`[data-xq-path="${CSS.escape(path)}"]`,
|
|
412
861
|
);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
862
|
+
// An editor's text comes after its toolbar, and the text is what needs fixing.
|
|
863
|
+
const target =
|
|
864
|
+
element?.querySelector<HTMLElement>('[contenteditable="true"]') ??
|
|
865
|
+
element?.querySelector<HTMLElement>(
|
|
866
|
+
'input:not([type=hidden]), textarea, button, [tabindex]:not([tabindex="-1"])',
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
// A closed tab stays mounted, and its panel opens a tick after the tab switches.
|
|
870
|
+
if (target && !target.closest('[hidden]')) {
|
|
418
871
|
element!.scrollIntoView?.({
|
|
419
872
|
block: 'center',
|
|
420
873
|
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
|
@@ -434,8 +887,15 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
434
887
|
let failedValidation = false;
|
|
435
888
|
|
|
436
889
|
processing.value = true;
|
|
437
|
-
await
|
|
438
|
-
|
|
890
|
+
await settled();
|
|
891
|
+
|
|
892
|
+
if (!validate()) {
|
|
893
|
+
processing.value = false;
|
|
894
|
+
submitOptions.onError?.({ ...errors });
|
|
895
|
+
submitOptions.onFinish?.();
|
|
896
|
+
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
439
899
|
|
|
440
900
|
router.visit(url, {
|
|
441
901
|
method: submitOptions.method ?? 'post',
|
|
@@ -450,6 +910,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
450
910
|
},
|
|
451
911
|
onSuccess: (page) => {
|
|
452
912
|
clearErrors();
|
|
913
|
+
markClean();
|
|
453
914
|
submitOptions.onSuccess?.(page);
|
|
454
915
|
},
|
|
455
916
|
onFinish: () => {
|
|
@@ -497,24 +958,36 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
497
958
|
file.name,
|
|
498
959
|
uploadFile(
|
|
499
960
|
schema.endpoints.upload,
|
|
500
|
-
{ token: schema.token ?? '', path, file },
|
|
961
|
+
{ token: schema.token ?? '', path, file, state: JSON.stringify(state.data) },
|
|
501
962
|
onProgress,
|
|
502
963
|
signal,
|
|
503
964
|
),
|
|
504
965
|
signal,
|
|
505
966
|
);
|
|
506
|
-
const tracked = request.finally(() => {
|
|
507
|
-
uploads.delete(tracked);
|
|
508
|
-
uploading.value = uploads.size;
|
|
509
|
-
});
|
|
510
967
|
|
|
511
|
-
|
|
512
|
-
uploading.value = uploads.size;
|
|
968
|
+
trackUpload(request);
|
|
513
969
|
|
|
514
970
|
return request;
|
|
515
971
|
}
|
|
516
972
|
|
|
973
|
+
/**
|
|
974
|
+
* Make submitting wait for this, as well as for uploads already running. A
|
|
975
|
+
* field with files still queued has no request for them yet.
|
|
976
|
+
*/
|
|
977
|
+
function trackUpload(work: Promise<unknown>): void {
|
|
978
|
+
const tracked = work
|
|
979
|
+
.catch(() => {})
|
|
980
|
+
.finally(() => {
|
|
981
|
+
uploads.delete(tracked);
|
|
982
|
+
uploading.value = uploads.size;
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
uploads.add(tracked);
|
|
986
|
+
uploading.value = uploads.size;
|
|
987
|
+
}
|
|
988
|
+
|
|
517
989
|
function dispose(): void {
|
|
990
|
+
clearIndicatorTimer();
|
|
518
991
|
scope.stop();
|
|
519
992
|
effects.clear();
|
|
520
993
|
}
|
|
@@ -523,6 +996,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
523
996
|
|
|
524
997
|
return {
|
|
525
998
|
id: schema.id,
|
|
999
|
+
locale: schema.locale,
|
|
526
1000
|
schema,
|
|
527
1001
|
components,
|
|
528
1002
|
state,
|
|
@@ -536,25 +1010,41 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
|
|
|
536
1010
|
root,
|
|
537
1011
|
queue,
|
|
538
1012
|
log: requests.entries,
|
|
1013
|
+
steppers,
|
|
1014
|
+
validating,
|
|
1015
|
+
t,
|
|
1016
|
+
markClean,
|
|
1017
|
+
isDirty,
|
|
539
1018
|
get,
|
|
540
1019
|
set,
|
|
541
1020
|
blur,
|
|
542
1021
|
isVisible,
|
|
543
1022
|
isRequired,
|
|
544
1023
|
isDisabled,
|
|
1024
|
+
text,
|
|
545
1025
|
isLoading,
|
|
1026
|
+
showsLoadingIndicator,
|
|
546
1027
|
evaluateCondition: (node: ConditionNode) => evaluateCondition(node, get),
|
|
547
1028
|
rowKeys,
|
|
548
1029
|
addRow,
|
|
1030
|
+
cloneRow,
|
|
549
1031
|
removeRow,
|
|
550
1032
|
moveRow,
|
|
551
1033
|
applyPatch,
|
|
552
1034
|
setErrors,
|
|
1035
|
+
addErrors,
|
|
553
1036
|
clearErrors,
|
|
1037
|
+
validateStep,
|
|
554
1038
|
focusFirstError,
|
|
1039
|
+
focusPath,
|
|
555
1040
|
submit,
|
|
556
1041
|
search,
|
|
557
1042
|
upload,
|
|
1043
|
+
trackUpload,
|
|
558
1044
|
dispose,
|
|
1045
|
+
settled,
|
|
1046
|
+
validate,
|
|
1047
|
+
mountAction,
|
|
1048
|
+
runAction,
|
|
559
1049
|
};
|
|
560
1050
|
}
|