@omnitend/dashboard-for-laravel 0.47.0 → 0.47.2
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/dist/components/extended/DXField.vue.d.ts +17 -3
- package/dist/components/extended/DXForm.vue.d.ts +5 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +10 -0
- package/dist/composables/useForm.d.ts +30 -0
- package/dist/dashboard-for-laravel.js +7813 -7723
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -6
- package/dist/utils/formErrorTargets.d.ts +23 -11
- package/docs/public/api-reference.json +5 -5
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/js/components/extended/DXField.vue +65 -6
- package/resources/js/components/extended/DXForm.vue +57 -6
- package/resources/js/components/extended/DXFormErrorSummary.vue +14 -0
- package/resources/js/components/extended/DXTableEditorModal.vue +2 -2
- package/resources/js/composables/useForm.ts +86 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +12 -6
- package/resources/js/utils/formErrorTargets.ts +94 -16
package/dist/types/index.d.ts
CHANGED
|
@@ -219,7 +219,9 @@ export interface FieldDefinition {
|
|
|
219
219
|
* For a field whose value is a list of rows (a `repeater`, or a span
|
|
220
220
|
* field that renders its own rows and owns `key.N.*` error keys): the
|
|
221
221
|
* row's own name, called with the row (`model[key][index]`, or `{}` when
|
|
222
|
-
* the model has no such row) and its 0-based index
|
|
222
|
+
* the model has no such row) and its 0-based index (for a failure's row
|
|
223
|
+
* that has since moved, its current index; once removed, the index it
|
|
224
|
+
* failed at: the index "(line N)" counts). When it returns a
|
|
223
225
|
* non-empty string, the error summary lists that row's errors under it
|
|
224
226
|
* alone ("Chaucer Ale (Pint): Enter a price.") instead of
|
|
225
227
|
* "<sub-field label> (line N)". Returning `undefined`, `null` or `""`
|
|
@@ -359,6 +361,8 @@ export interface BaseFieldDef {
|
|
|
359
361
|
plaintext?: MaybeFn<boolean>;
|
|
360
362
|
submit?: boolean;
|
|
361
363
|
errorKeys?: string[];
|
|
364
|
+
/** On a repeater, or a span field that renders its own rows. */
|
|
365
|
+
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
362
366
|
when?: MaybeFn<boolean>;
|
|
363
367
|
show?: () => boolean;
|
|
364
368
|
}
|
|
@@ -461,7 +465,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
|
|
|
461
465
|
export interface RepeaterFieldDef extends BaseFieldDef {
|
|
462
466
|
type: "repeater";
|
|
463
467
|
fields?: FieldDef[];
|
|
464
|
-
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
465
468
|
addLabel?: string;
|
|
466
469
|
minItems?: number;
|
|
467
470
|
maxItems?: number;
|
|
@@ -508,13 +511,16 @@ export interface FormTab {
|
|
|
508
511
|
}
|
|
509
512
|
/**
|
|
510
513
|
* Slot props of DXField's `password-actions` slot (DXForm:
|
|
511
|
-
* `password-actions(<key>)`): content placed beside a password input,
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
+
* `password-actions(<key>)`): content placed beside a password input, such as
|
|
515
|
+
* a "Generate password" button. It renders after the input group (the input
|
|
516
|
+
* and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
|
|
517
|
+
* onto its own line below the input when the row is too narrow to keep
|
|
518
|
+
* `--dx-password-input-min-width`. `setRevealed` drives the same state the
|
|
519
|
+
* eye toggles.
|
|
514
520
|
*
|
|
515
521
|
* ```vue
|
|
516
522
|
* <template #password-actions(password)="{ update, setRevealed, disabled }">
|
|
517
|
-
* <DButton variant="
|
|
523
|
+
* <DButton variant="secondary" :disabled="disabled"
|
|
518
524
|
* @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
|
|
519
525
|
* </template>
|
|
520
526
|
* ```
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which field a validation-error key belongs to (#194).
|
|
3
|
-
*
|
|
4
|
-
* One pure function decides, so the error summary's rows, the tab a failed
|
|
5
|
-
* submit selects and the field it scrolls to can never disagree. No Vue: the
|
|
6
|
-
* caller injects visibility and label resolution, so DXForm can pass its own
|
|
7
|
-
* `when`/`context`-aware predicates and a standalone summary can use the
|
|
8
|
-
* defaults below.
|
|
9
|
-
*/
|
|
10
1
|
import type { FieldDefinition, FormTab } from "../types";
|
|
11
|
-
import type { ValidationErrors } from "../composables/useForm";
|
|
2
|
+
import type { FailedRow, ValidationErrors } from "../composables/useForm";
|
|
12
3
|
/** One error key and where it belongs. */
|
|
13
4
|
export interface ErrorTarget {
|
|
14
5
|
/** The key as the server sent it (`lines.0.price`). */
|
|
15
6
|
errorKey: string;
|
|
7
|
+
/**
|
|
8
|
+
* Where to take the user for this error now. The same as `errorKey`,
|
|
9
|
+
* except for a row error resolved with `failedRows`: the key at the
|
|
10
|
+
* failed row's CURRENT index (`lines.1.price` once row 0 is removed),
|
|
11
|
+
* or the owning field's key when that row is gone (removed, replaced, or
|
|
12
|
+
* flagged by the field's `softDeleteKey`); DXForm then focuses the
|
|
13
|
+
* field's container, not a row. Always set by `resolveErrorTargets`;
|
|
14
|
+
* optional so targets built by hand still type.
|
|
15
|
+
*/
|
|
16
|
+
focusKey?: string;
|
|
16
17
|
/** The server's messages for that key, as sent. */
|
|
17
18
|
messages: string[];
|
|
18
19
|
/**
|
|
@@ -55,6 +56,14 @@ export interface ResolveErrorTargetsOptions {
|
|
|
55
56
|
* humanised key, so a broken label can never hide an error.
|
|
56
57
|
*/
|
|
57
58
|
resolveLabel?: (field: FieldDefinition, labelModel: Record<string, any>) => string | null | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* The rows a failed submit's errors named, as captured then
|
|
61
|
+
* (`SubmitFailure.failedRows`). Pass it when resolving that failure's
|
|
62
|
+
* errors after the form may have changed (the error summary does), so a
|
|
63
|
+
* row error keeps the name of the row that failed and points at where
|
|
64
|
+
* that row is now. Leave it out for the form's live errors.
|
|
65
|
+
*/
|
|
66
|
+
failedRows?: Record<string, FailedRow>;
|
|
58
67
|
}
|
|
59
68
|
/**
|
|
60
69
|
* `delivery_date` → "Delivery date"; `lines.0.price` → "Lines price (line 1)".
|
|
@@ -79,7 +88,10 @@ export declare function humaniseErrorKey(key: string): string;
|
|
|
79
88
|
* Labels: the owning field's label; for `field.N.sub` the sub-field's label
|
|
80
89
|
* (when the field defines `fields` and one has that key) or the field's own,
|
|
81
90
|
* plus "(line N+1)" — unless the field's `rowLabel` names that row, when
|
|
82
|
-
* the row's name is the whole label
|
|
91
|
+
* the row's name is the whole label. With `failedRows`, a row key's label
|
|
92
|
+
* reads the row as it was when the submit failed, and its `focusKey` and
|
|
93
|
+
* line number follow that row to where it is now (see `ownedTarget`); for
|
|
94
|
+
* any other nested key (a media map's
|
|
83
95
|
* `image_media.<uuid>`) the field's label. An unowned key is humanised.
|
|
84
96
|
*
|
|
85
97
|
* Display lines: "Label: message", except that the label is left off when
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated": "2026-10-
|
|
2
|
+
"generated": "2026-10-21T13:19:32.045Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "0.47.
|
|
5
|
+
"version": "0.47.2"
|
|
6
6
|
},
|
|
7
7
|
"components": {
|
|
8
8
|
"base": [
|
|
@@ -2205,7 +2205,7 @@
|
|
|
2205
2205
|
"name": "(value: any) => void"
|
|
2206
2206
|
},
|
|
2207
2207
|
"name": "update",
|
|
2208
|
-
"description": "Setter that writes the value back and clears the field's validation error."
|
|
2208
|
+
"description": "Setter that writes the value back and clears the field's validation error, and every error under `<key>.` when the value is an array whose rows were added, removed, replaced or reordered (rows the slot draws itself)."
|
|
2209
2209
|
}
|
|
2210
2210
|
]
|
|
2211
2211
|
},
|
|
@@ -2287,7 +2287,7 @@
|
|
|
2287
2287
|
"name": "(value: any) => void"
|
|
2288
2288
|
},
|
|
2289
2289
|
"name": "update",
|
|
2290
|
-
"description": "Setter that writes the value back and clears the field's validation error."
|
|
2290
|
+
"description": "Setter that writes the value back and clears the field's validation error, and every error under `<key>.` when the value is an array whose rows were added, removed, replaced or reordered (rows the slot draws itself)."
|
|
2291
2291
|
}
|
|
2292
2292
|
]
|
|
2293
2293
|
},
|
|
@@ -4433,7 +4433,7 @@
|
|
|
4433
4433
|
},
|
|
4434
4434
|
{
|
|
4435
4435
|
"name": "`edit-password-actions(${key})`",
|
|
4436
|
-
"description": "Extra controls beside password field `<key>`'s input in the edit/create modal,
|
|
4436
|
+
"description": "Extra controls beside password field `<key>`'s input in the edit/create modal, after its input group (the input and the reveal (eye) toggle); wraps below the input when the row is too narrow — e.g. a \"Generate password\" button. Name it `edit-password-actions(<fieldKey>)`.",
|
|
4437
4437
|
"bindings": [
|
|
4438
4438
|
{
|
|
4439
4439
|
"name": "name",
|
package/docs/public/docs-map.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Documentation Map
|
|
2
2
|
|
|
3
3
|
> Auto-generated hierarchical overview of all documentation
|
|
4
|
-
> Last updated: 2026-10-
|
|
4
|
+
> Last updated: 2026-10-21T13:19:32.105Z
|
|
5
5
|
|
|
6
6
|
This file provides a complete map of all available documentation for AI agents and developers.
|
|
7
7
|
|
package/package.json
CHANGED
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
@binding {FieldDefinition} field The field definition being rendered.
|
|
23
23
|
@binding {any} model The model passed to field predicates (defaults to the live form data).
|
|
24
24
|
@binding {any} value The current field value.
|
|
25
|
-
@binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
|
|
25
|
+
@binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error, and every error under `<key>.` when the value is an array whose rows were added, removed, replaced or reordered (rows the slot draws itself).
|
|
26
26
|
-->
|
|
27
27
|
<slot
|
|
28
28
|
name="span"
|
|
29
29
|
:field="field"
|
|
30
30
|
:model="model"
|
|
31
31
|
:value="fieldValue"
|
|
32
|
-
:update="
|
|
32
|
+
:update="setValueFromSlot"
|
|
33
33
|
/>
|
|
34
34
|
</div>
|
|
35
35
|
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
:field="field"
|
|
86
86
|
:model="model"
|
|
87
87
|
:value="fieldValue"
|
|
88
|
-
:update="
|
|
88
|
+
:update="setValueFromSlot"
|
|
89
89
|
/>
|
|
90
90
|
</div>
|
|
91
91
|
<!--
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
@binding {FieldDefinition} field The field definition being rendered.
|
|
94
94
|
@binding {any} model The model passed to field predicates (defaults to the live form data).
|
|
95
95
|
@binding {any} value The current field value.
|
|
96
|
-
@binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
|
|
96
|
+
@binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error, and every error under `<key>.` when the value is an array whose rows were added, removed, replaced or reordered (rows the slot draws itself).
|
|
97
97
|
-->
|
|
98
98
|
<slot
|
|
99
99
|
v-else-if="showValueSlot && $slots.value"
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
:field="field"
|
|
102
102
|
:model="model"
|
|
103
103
|
:value="fieldValue"
|
|
104
|
-
:update="
|
|
104
|
+
:update="setValueFromSlot"
|
|
105
105
|
/>
|
|
106
106
|
<template v-else>
|
|
107
107
|
<!-- Checkbox (horizontal): the row label (shell #label) names the
|
|
@@ -420,6 +420,7 @@ import {
|
|
|
420
420
|
onBeforeUnmount,
|
|
421
421
|
provide,
|
|
422
422
|
ref,
|
|
423
|
+
toRaw,
|
|
423
424
|
useId,
|
|
424
425
|
useSlots,
|
|
425
426
|
watch,
|
|
@@ -744,6 +745,59 @@ function setValue(value: any): void {
|
|
|
744
745
|
props.form.clearError(errorKey.value);
|
|
745
746
|
}
|
|
746
747
|
|
|
748
|
+
/** The raw rows of an array value, in order, or null for any other value. */
|
|
749
|
+
function rowIdentitiesOf(value: unknown): unknown[] | null {
|
|
750
|
+
return Array.isArray(value) ? value.map((row) => toRaw(row)) : null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The field's rows as last seen: refreshed by the watcher below whenever the
|
|
755
|
+
* array or its membership changes (not on edits inside a row), and by each
|
|
756
|
+
* slot `update`. The watcher runs before the next render, so a slot that
|
|
757
|
+
* changes the rows in place and calls `update` in the same handler is still
|
|
758
|
+
* compared against the rows from before the change.
|
|
759
|
+
*/
|
|
760
|
+
let lastSeenRows: unknown[] | null = null;
|
|
761
|
+
watch(
|
|
762
|
+
() => rowIdentitiesOf(fieldValue.value),
|
|
763
|
+
(rows) => {
|
|
764
|
+
lastSeenRows = rows;
|
|
765
|
+
},
|
|
766
|
+
{ immediate: true },
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
function haveRowsMoved(previous: unknown[] | null, next: unknown[]): boolean {
|
|
770
|
+
if (previous === null || previous.length !== next.length) return true;
|
|
771
|
+
return next.some((row, index) => row !== previous[index]);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* `update` for the `span` and `value` slots (and so DXTable's `edit-span` /
|
|
776
|
+
* `edit-value`). Like a plain set, it clears the field's own error. When the
|
|
777
|
+
* value is an array whose rows changed (added, removed, replaced or
|
|
778
|
+
* reordered, compared by identity with the rows as last seen), it also
|
|
779
|
+
* clears every error under `<key>.` (`lines.3.price`), as the repeater's add
|
|
780
|
+
* and remove do: a per-row error would otherwise stay under whichever row
|
|
781
|
+
* now sits at its index. An update that keeps the same rows in the same
|
|
782
|
+
* order (a row editor writing the array back on each keystroke), or a
|
|
783
|
+
* value that is not an array (an `address` object), keeps the nested
|
|
784
|
+
* errors: they still name the right place. They come back on the next
|
|
785
|
+
* submit either way.
|
|
786
|
+
*/
|
|
787
|
+
function setValueFromSlot(value: any): void {
|
|
788
|
+
const nextRows = rowIdentitiesOf(value);
|
|
789
|
+
const rowsMoved = nextRows !== null && haveRowsMoved(lastSeenRows, nextRows);
|
|
790
|
+
setValue(value);
|
|
791
|
+
if (nextRows !== null) lastSeenRows = nextRows;
|
|
792
|
+
if (!rowsMoved) return;
|
|
793
|
+
const prefix = `${errorKey.value}.`;
|
|
794
|
+
for (const key of Object.keys(props.form.errors)) {
|
|
795
|
+
if (key.startsWith(prefix)) {
|
|
796
|
+
props.form.clearError(key);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
747
801
|
const fieldState = computed(() => props.form.getState(errorKey.value));
|
|
748
802
|
|
|
749
803
|
// A label function that throws falls back to the humanised key, as the
|
|
@@ -882,8 +936,13 @@ function setPasswordRevealed(revealed: boolean): void {
|
|
|
882
936
|
// v-for is keyed on `field.key`, which is stable across rows) and swaps in a
|
|
883
937
|
// fresh form per record. Without this, revealing row A's password leaves row
|
|
884
938
|
// B's — and the create form's — showing in clear text.
|
|
939
|
+
// Two sources, not one getter returning `[form, key]`: that array is new on
|
|
940
|
+
// every run, so the callback would fire on ANY change the getter read,
|
|
941
|
+
// including a new `field` object from a consumer that rebuilds its fields
|
|
942
|
+
// from form data on each edit, re-masking straight after `setRevealed(true)`
|
|
943
|
+
// or the first keystroke after the eye.
|
|
885
944
|
watch(
|
|
886
|
-
() =>
|
|
945
|
+
[() => props.form, () => props.field.key],
|
|
887
946
|
() => {
|
|
888
947
|
passwordRevealed.value = false;
|
|
889
948
|
},
|
|
@@ -227,7 +227,11 @@ import DXFormField from "./DXFormField.vue";
|
|
|
227
227
|
import DXSaveButton from "./DXSaveButton.vue";
|
|
228
228
|
import DXFormErrorSummary, { type ErrorSummarySelection } from "./DXFormErrorSummary.vue";
|
|
229
229
|
import { resolveErrorTargets, type ErrorTarget } from "../../utils/formErrorTargets";
|
|
230
|
-
import type {
|
|
230
|
+
import type {
|
|
231
|
+
SubmitFailure,
|
|
232
|
+
UseFormReturn,
|
|
233
|
+
ValidationErrors,
|
|
234
|
+
} from "../../composables/useForm";
|
|
231
235
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
232
236
|
import { useContainerWidth } from "../../composables/useContainerWidth";
|
|
233
237
|
import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
|
|
@@ -590,13 +594,17 @@ const visibleTabKeys = computed<string[]>(() =>
|
|
|
590
594
|
* field definition (consumer-rendered content) still owns its own key and
|
|
591
595
|
* keys nested under it.
|
|
592
596
|
*/
|
|
593
|
-
function errorTargetsFor(
|
|
597
|
+
function errorTargetsFor(
|
|
598
|
+
errors: ValidationErrors | null | undefined,
|
|
599
|
+
failedRows?: SubmitFailure["failedRows"],
|
|
600
|
+
): ErrorTarget[] {
|
|
594
601
|
return resolveErrorTargets(errors, {
|
|
595
602
|
fields: resolvedFields.value,
|
|
596
603
|
tabs: hasTabs.value ? props.tabs : undefined,
|
|
597
604
|
model: model.value,
|
|
598
605
|
isFieldVisible,
|
|
599
606
|
isTabVisible: (tab) => visibleTabKeys.value.includes(tab.key),
|
|
607
|
+
failedRows,
|
|
600
608
|
});
|
|
601
609
|
}
|
|
602
610
|
|
|
@@ -605,10 +613,14 @@ const liveErrorTargets = computed<ErrorTarget[]>(() =>
|
|
|
605
613
|
erroredKeys.value.length === 0 ? [] : errorTargetsFor(resolvedForm.value.errors),
|
|
606
614
|
);
|
|
607
615
|
|
|
608
|
-
/**
|
|
616
|
+
/**
|
|
617
|
+
* Targets for what the last failed submit returned (the summary's rows). The
|
|
618
|
+
* failure's captured rows keep a row error naming, and pointing at, the row
|
|
619
|
+
* that failed after rows are removed or reordered.
|
|
620
|
+
*/
|
|
609
621
|
const summaryTargets = computed<ErrorTarget[]>(() => {
|
|
610
622
|
const failure = resolvedForm.value.submitFailure;
|
|
611
|
-
return failure ? errorTargetsFor(failure.errors) : [];
|
|
623
|
+
return failure ? errorTargetsFor(failure.errors, failure.failedRows) : [];
|
|
612
624
|
});
|
|
613
625
|
|
|
614
626
|
/** Key of the first visible tab owning a visible errored field, or null. */
|
|
@@ -1073,6 +1085,33 @@ function focusSelectedTab(): void {
|
|
|
1073
1085
|
document.getElementById(buttonIdFor(selectedKey))?.focus();
|
|
1074
1086
|
}
|
|
1075
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Focus a field's container rather than any control inside it: the field's
|
|
1090
|
+
* labelled group when it has one (a repeater's `fieldset`, announced by its
|
|
1091
|
+
* legend), else the marked element itself. It is made focusable for this
|
|
1092
|
+
* focus only (`tabindex="-1"`, removed again on blur), so the Tab order is
|
|
1093
|
+
* unchanged. Used when the row an error named is gone: every control inside
|
|
1094
|
+
* the field belongs to some other row. True once it holds focus.
|
|
1095
|
+
*/
|
|
1096
|
+
function focusFieldContainer(element: HTMLElement): boolean {
|
|
1097
|
+
const container =
|
|
1098
|
+
element.querySelector<HTMLElement>(':scope > fieldset, :scope > [role="group"]') ??
|
|
1099
|
+
element;
|
|
1100
|
+
if (!container.hasAttribute("tabindex")) {
|
|
1101
|
+
container.setAttribute("tabindex", "-1");
|
|
1102
|
+
container.addEventListener(
|
|
1103
|
+
"blur",
|
|
1104
|
+
() => container.removeAttribute("tabindex"),
|
|
1105
|
+
{ once: true },
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
element.scrollIntoView({ block: "nearest" });
|
|
1109
|
+
container.focus({ preventScroll: true });
|
|
1110
|
+
if (document.activeElement === container) return true;
|
|
1111
|
+
if (container.getAttribute("tabindex") === "-1") container.removeAttribute("tabindex");
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1076
1115
|
/**
|
|
1077
1116
|
* Take the user to an error a summary lists: select its tab, wait for the
|
|
1078
1117
|
* pane to render, look the field up in the form (its marker, anywhere
|
|
@@ -1085,7 +1124,11 @@ function focusSelectedTab(): void {
|
|
|
1085
1124
|
* its key whenever its element leaves the DOM (a re-render replacing it),
|
|
1086
1125
|
* and a moment with none at all is waited out. When no marker renders
|
|
1087
1126
|
* within the field's render frames, or nothing in it takes focus, focus
|
|
1088
|
-
* moves to the selected tab's button.
|
|
1127
|
+
* moves to the selected tab's button. When the row the error named is gone
|
|
1128
|
+
* (`focusKey` is the field's key while `errorKey` names something under it:
|
|
1129
|
+
* the row was removed, replaced or soft-deleted), the field's container
|
|
1130
|
+
* takes focus instead of a control, since every control in it belongs to
|
|
1131
|
+
* another row (`focusFieldContainer`). Exposed so a summary rendered OUTSIDE
|
|
1089
1132
|
* the form (DXTable's modal footer) can drive it.
|
|
1090
1133
|
*/
|
|
1091
1134
|
async function focusErrorTarget(target: ErrorSummarySelection): Promise<void> {
|
|
@@ -1093,8 +1136,12 @@ async function focusErrorTarget(target: ErrorSummarySelection): Promise<void> {
|
|
|
1093
1136
|
commitTabKey(target.tabKey);
|
|
1094
1137
|
}
|
|
1095
1138
|
const token = ++revealToken;
|
|
1139
|
+
const isTargetGone =
|
|
1140
|
+
target.focusKey === target.fieldKey &&
|
|
1141
|
+
target.errorKey !== undefined &&
|
|
1142
|
+
target.errorKey !== target.fieldKey;
|
|
1096
1143
|
let element = await renderedErrorElement(
|
|
1097
|
-
target.errorKey ?? target.fieldKey,
|
|
1144
|
+
target.focusKey ?? target.errorKey ?? target.fieldKey,
|
|
1098
1145
|
target.fieldKey,
|
|
1099
1146
|
);
|
|
1100
1147
|
if (token !== revealToken) return;
|
|
@@ -1102,6 +1149,10 @@ async function focusErrorTarget(target: ErrorSummarySelection): Promise<void> {
|
|
|
1102
1149
|
focusSelectedTab();
|
|
1103
1150
|
return;
|
|
1104
1151
|
}
|
|
1152
|
+
if (isTargetGone) {
|
|
1153
|
+
if (!focusFieldContainer(element)) focusSelectedTab();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1105
1156
|
const path = element.getAttribute("data-dx-field-key") ?? target.fieldKey;
|
|
1106
1157
|
scrollFieldIntoView(element);
|
|
1107
1158
|
|
|
@@ -75,6 +75,16 @@ export interface ErrorSummarySelection {
|
|
|
75
75
|
tabKey: string | null;
|
|
76
76
|
/** The error key as the server sent it. */
|
|
77
77
|
errorKey: string;
|
|
78
|
+
/**
|
|
79
|
+
* Where to take the user: the error key at the failed row's current
|
|
80
|
+
* index, or the field's key when that row is gone (see
|
|
81
|
+
* `ErrorTarget.focusKey`), when DXForm's `focusErrorTarget` focuses the
|
|
82
|
+
* field's container rather than any row's control. Equal to `errorKey`
|
|
83
|
+
* for everything else.
|
|
84
|
+
* Always emitted; optional so a selection built by hand for DXForm's
|
|
85
|
+
* exposed `focusErrorTarget` still types (it then uses `errorKey`).
|
|
86
|
+
*/
|
|
87
|
+
focusKey?: string;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
interface Props {
|
|
@@ -171,6 +181,7 @@ const targets = computed<ErrorTarget[]>(() => {
|
|
|
171
181
|
isFieldVisible: props.isFieldVisible,
|
|
172
182
|
isTabVisible: props.isTabVisible,
|
|
173
183
|
resolveLabel: props.resolveLabel,
|
|
184
|
+
failedRows: failure.value.failedRows,
|
|
174
185
|
});
|
|
175
186
|
});
|
|
176
187
|
|
|
@@ -178,6 +189,7 @@ interface Row {
|
|
|
178
189
|
id: string;
|
|
179
190
|
text: string;
|
|
180
191
|
errorKey: string;
|
|
192
|
+
focusKey: string;
|
|
181
193
|
fieldKey: string | null;
|
|
182
194
|
tabKey: string | null;
|
|
183
195
|
}
|
|
@@ -188,6 +200,7 @@ const rows = computed<Row[]>(() =>
|
|
|
188
200
|
id: `${target.errorKey}#${index}`,
|
|
189
201
|
text,
|
|
190
202
|
errorKey: target.errorKey,
|
|
203
|
+
focusKey: target.focusKey ?? target.errorKey,
|
|
191
204
|
fieldKey: target.fieldKey,
|
|
192
205
|
tabKey: target.tabKey,
|
|
193
206
|
})),
|
|
@@ -227,6 +240,7 @@ function selectRow(row: Row): void {
|
|
|
227
240
|
fieldKey: row.fieldKey,
|
|
228
241
|
tabKey: row.tabKey,
|
|
229
242
|
errorKey: row.errorKey,
|
|
243
|
+
focusKey: row.focusKey,
|
|
230
244
|
});
|
|
231
245
|
}
|
|
232
246
|
</script>
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
</template>
|
|
117
117
|
|
|
118
118
|
<!-- Forward edit-password-actions(key) → password-actions(key):
|
|
119
|
-
content
|
|
119
|
+
content after the password input group (input and eye),
|
|
120
120
|
with a handle on dfl's own reveal (eye) state. -->
|
|
121
121
|
<template
|
|
122
122
|
v-for="key in editPasswordActionsSlotKeys"
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
#[`password-actions(${key})`]="sp"
|
|
125
125
|
>
|
|
126
126
|
<!--
|
|
127
|
-
@slot Extra controls beside password field `<key>`'s input in the edit/create modal,
|
|
127
|
+
@slot Extra controls beside password field `<key>`'s input in the edit/create modal, after its input group (the input and the reveal (eye) toggle); wraps below the input when the row is too narrow — e.g. a "Generate password" button. Name it `edit-password-actions(<fieldKey>)`.
|
|
128
128
|
@binding {object} item The row being edited (null in create mode).
|
|
129
129
|
@binding {any} value The current field value.
|
|
130
130
|
@binding {Function} update Call with a new value to update the field.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
reactive,
|
|
3
3
|
computed,
|
|
4
|
+
markRaw,
|
|
4
5
|
toRaw,
|
|
5
6
|
type ComputedRef,
|
|
6
7
|
type WritableComputedRef,
|
|
7
8
|
} from "vue";
|
|
8
9
|
import { api, type ApiError } from "../utils/api";
|
|
10
|
+
import { getByPath } from "../utils/objectPath";
|
|
9
11
|
|
|
10
12
|
export interface ValidationErrors {
|
|
11
13
|
[key: string]: string[];
|
|
@@ -24,6 +26,37 @@ export interface SubmitFailure {
|
|
|
24
26
|
message: string;
|
|
25
27
|
/** The validation errors the submit returned (`{}` when none). */
|
|
26
28
|
errors: ValidationErrors;
|
|
29
|
+
/**
|
|
30
|
+
* The rows the errors named, keyed by row path: the path up to the
|
|
31
|
+
* key's FIRST numeric segment (`lines.2` for `lines.2.price` and for
|
|
32
|
+
* `lines.2.options.0.name`; a row nested inside a row is not recorded,
|
|
33
|
+
* since the summary follows the outer row). Absent when no error key
|
|
34
|
+
* names a row that exists in the form data. The error summary uses it
|
|
35
|
+
* to keep naming and finding the row that failed after rows are removed
|
|
36
|
+
* or reordered.
|
|
37
|
+
*
|
|
38
|
+
* The rows are read from the form data when the failure response
|
|
39
|
+
* ARRIVES, not when the request was sent. Rows added, removed or
|
|
40
|
+
* reordered while the request was in flight, or a submit `transform`
|
|
41
|
+
* that drops or reorders rows, can attach an error to the wrong row
|
|
42
|
+
* here, exactly as a bare index key always could.
|
|
43
|
+
*/
|
|
44
|
+
failedRows?: Record<string, FailedRow>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One row a failed submit's errors named (see `SubmitFailure.failedRows`). */
|
|
48
|
+
export interface FailedRow {
|
|
49
|
+
/**
|
|
50
|
+
* The row object itself (raw, not a reactive proxy), so its current
|
|
51
|
+
* index can be found by identity. A row array replaced wholesale (a
|
|
52
|
+
* reload) holds new objects, and the row then counts as gone.
|
|
53
|
+
*/
|
|
54
|
+
row: object;
|
|
55
|
+
/**
|
|
56
|
+
* A copy of the row taken when the submit failed, for labels: a row's
|
|
57
|
+
* name (`rowLabel`) reads it, so later edits do not rename the error.
|
|
58
|
+
*/
|
|
59
|
+
snapshot: Record<string, any>;
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
export interface FormSubmitOptions<TPayload = unknown, TResponse = unknown> {
|
|
@@ -222,6 +255,52 @@ function submitFailureFrom(error: unknown): SubmitFailure | null {
|
|
|
222
255
|
};
|
|
223
256
|
}
|
|
224
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A copy of a row for `FailedRow.snapshot`: a deep clone when the row allows
|
|
260
|
+
* one (`File`, `Blob` and `Date` values clone fine), else a shallow copy, so
|
|
261
|
+
* a row `structuredClone` refuses still gets a name. It refuses a row
|
|
262
|
+
* holding a function, a symbol, a DOM node or a proxy (a reactive object
|
|
263
|
+
* nested in the row: the row itself is raw, the objects inside it may not
|
|
264
|
+
* be). A class instance does NOT make it throw: it is cloned as a plain
|
|
265
|
+
* object, losing its prototype, getters and private fields, so a `rowLabel`
|
|
266
|
+
* reading a getter on such a value reads `undefined` (and falls back to the
|
|
267
|
+
* line form).
|
|
268
|
+
*/
|
|
269
|
+
function snapshotRow(row: object): Record<string, any> {
|
|
270
|
+
try {
|
|
271
|
+
return structuredClone(row) as Record<string, any>;
|
|
272
|
+
} catch {
|
|
273
|
+
return { ...(row as Record<string, any>) };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The `failedRows` for a failure's error keys, read from the form data as it
|
|
279
|
+
* is when the failure is recorded. Only the row at a key's first numeric
|
|
280
|
+
* segment is recorded: that is the only one the error summary reads
|
|
281
|
+
* (`<field key>.<index>`), so deeper rows would be cloned for nothing.
|
|
282
|
+
* Undefined when no key names a row.
|
|
283
|
+
*/
|
|
284
|
+
function failedRowsFor(
|
|
285
|
+
errors: ValidationErrors,
|
|
286
|
+
data: Record<string, any>,
|
|
287
|
+
): Record<string, FailedRow> | undefined {
|
|
288
|
+
const rows: Record<string, FailedRow> = {};
|
|
289
|
+
for (const errorKey of Object.keys(errors)) {
|
|
290
|
+
const segments = errorKey.split(".");
|
|
291
|
+
const firstRowSegment = segments.findIndex(
|
|
292
|
+
(segment, index) => index > 0 && /^\d+$/.test(segment),
|
|
293
|
+
);
|
|
294
|
+
if (firstRowSegment < 0) continue;
|
|
295
|
+
const rowPath = segments.slice(0, firstRowSegment + 1).join(".");
|
|
296
|
+
if (rows[rowPath] !== undefined) continue;
|
|
297
|
+
const row = toRaw(getByPath(data, rowPath));
|
|
298
|
+
if (row === null || typeof row !== "object") continue;
|
|
299
|
+
rows[rowPath] = markRaw({ row, snapshot: snapshotRow(row) });
|
|
300
|
+
}
|
|
301
|
+
return Object.keys(rows).length > 0 ? rows : undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
225
304
|
function errorsFromLaravel(error: any): {
|
|
226
305
|
errors: ValidationErrors;
|
|
227
306
|
message: string;
|
|
@@ -523,6 +602,13 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
523
602
|
setErrors(errors);
|
|
524
603
|
setMessage(message);
|
|
525
604
|
if (failure) {
|
|
605
|
+
const failedRows = failedRowsFor(
|
|
606
|
+
failure.errors,
|
|
607
|
+
toRaw(state.data) as Record<string, any>,
|
|
608
|
+
);
|
|
609
|
+
if (failedRows !== undefined) {
|
|
610
|
+
failure.failedRows = failedRows;
|
|
611
|
+
}
|
|
526
612
|
state.submitFailure = failure;
|
|
527
613
|
state.failedSubmitCount += 1;
|
|
528
614
|
}
|
package/resources/js/index.ts
CHANGED
|
@@ -278,7 +278,9 @@ export interface FieldDefinition {
|
|
|
278
278
|
* For a field whose value is a list of rows (a `repeater`, or a span
|
|
279
279
|
* field that renders its own rows and owns `key.N.*` error keys): the
|
|
280
280
|
* row's own name, called with the row (`model[key][index]`, or `{}` when
|
|
281
|
-
* the model has no such row) and its 0-based index
|
|
281
|
+
* the model has no such row) and its 0-based index (for a failure's row
|
|
282
|
+
* that has since moved, its current index; once removed, the index it
|
|
283
|
+
* failed at: the index "(line N)" counts). When it returns a
|
|
282
284
|
* non-empty string, the error summary lists that row's errors under it
|
|
283
285
|
* alone ("Chaucer Ale (Pint): Enter a price.") instead of
|
|
284
286
|
* "<sub-field label> (line N)". Returning `undefined`, `null` or `""`
|
|
@@ -458,6 +460,8 @@ export interface BaseFieldDef {
|
|
|
458
460
|
plaintext?: MaybeFn<boolean>;
|
|
459
461
|
submit?: boolean;
|
|
460
462
|
errorKeys?: string[];
|
|
463
|
+
/** On a repeater, or a span field that renders its own rows. */
|
|
464
|
+
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
461
465
|
when?: MaybeFn<boolean>;
|
|
462
466
|
show?: () => boolean;
|
|
463
467
|
}
|
|
@@ -597,7 +601,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
|
|
|
597
601
|
export interface RepeaterFieldDef extends BaseFieldDef {
|
|
598
602
|
type: "repeater";
|
|
599
603
|
fields?: FieldDef[];
|
|
600
|
-
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
601
604
|
addLabel?: string;
|
|
602
605
|
minItems?: number;
|
|
603
606
|
maxItems?: number;
|
|
@@ -673,13 +676,16 @@ export interface FormTab {
|
|
|
673
676
|
|
|
674
677
|
/**
|
|
675
678
|
* Slot props of DXField's `password-actions` slot (DXForm:
|
|
676
|
-
* `password-actions(<key>)`): content placed beside a password input,
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
+
* `password-actions(<key>)`): content placed beside a password input, such as
|
|
680
|
+
* a "Generate password" button. It renders after the input group (the input
|
|
681
|
+
* and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
|
|
682
|
+
* onto its own line below the input when the row is too narrow to keep
|
|
683
|
+
* `--dx-password-input-min-width`. `setRevealed` drives the same state the
|
|
684
|
+
* eye toggles.
|
|
679
685
|
*
|
|
680
686
|
* ```vue
|
|
681
687
|
* <template #password-actions(password)="{ update, setRevealed, disabled }">
|
|
682
|
-
* <DButton variant="
|
|
688
|
+
* <DButton variant="secondary" :disabled="disabled"
|
|
683
689
|
* @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
|
|
684
690
|
* </template>
|
|
685
691
|
* ```
|