@omnitend/dashboard-for-laravel 0.47.0 → 0.47.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/dist/components/extended/DXField.vue.d.ts +12 -3
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +8 -0
- package/dist/composables/useForm.d.ts +22 -0
- package/dist/dashboard-for-laravel.js +6031 -5973
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +6 -6
- 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 +9 -5
- package/dist/utils/formErrorTargets.d.ts +21 -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 +29 -6
- package/resources/js/components/extended/DXForm.vue +17 -5
- package/resources/js/components/extended/DXFormErrorSummary.vue +12 -0
- package/resources/js/components/extended/DXTableEditorModal.vue +2 -2
- package/resources/js/composables/useForm.ts +68 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +9 -5
- package/resources/js/utils/formErrorTargets.ts +82 -16
package/dist/types/index.d.ts
CHANGED
|
@@ -359,6 +359,8 @@ export interface BaseFieldDef {
|
|
|
359
359
|
plaintext?: MaybeFn<boolean>;
|
|
360
360
|
submit?: boolean;
|
|
361
361
|
errorKeys?: string[];
|
|
362
|
+
/** On a repeater, or a span field that renders its own rows. */
|
|
363
|
+
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
362
364
|
when?: MaybeFn<boolean>;
|
|
363
365
|
show?: () => boolean;
|
|
364
366
|
}
|
|
@@ -461,7 +463,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
|
|
|
461
463
|
export interface RepeaterFieldDef extends BaseFieldDef {
|
|
462
464
|
type: "repeater";
|
|
463
465
|
fields?: FieldDef[];
|
|
464
|
-
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
465
466
|
addLabel?: string;
|
|
466
467
|
minItems?: number;
|
|
467
468
|
maxItems?: number;
|
|
@@ -508,13 +509,16 @@ export interface FormTab {
|
|
|
508
509
|
}
|
|
509
510
|
/**
|
|
510
511
|
* Slot props of DXField's `password-actions` slot (DXForm:
|
|
511
|
-
* `password-actions(<key>)`): content placed beside a password input,
|
|
512
|
-
*
|
|
513
|
-
*
|
|
512
|
+
* `password-actions(<key>)`): content placed beside a password input, such as
|
|
513
|
+
* a "Generate password" button. It renders after the input group (the input
|
|
514
|
+
* and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
|
|
515
|
+
* onto its own line below the input when the row is too narrow to keep
|
|
516
|
+
* `--dx-password-input-min-width`. `setRevealed` drives the same state the
|
|
517
|
+
* eye toggles.
|
|
514
518
|
*
|
|
515
519
|
* ```vue
|
|
516
520
|
* <template #password-actions(password)="{ update, setRevealed, disabled }">
|
|
517
|
-
* <DButton variant="
|
|
521
|
+
* <DButton variant="secondary" :disabled="disabled"
|
|
518
522
|
* @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
|
|
519
523
|
* </template>
|
|
520
524
|
* ```
|
|
@@ -1,18 +1,17 @@
|
|
|
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. Always set by
|
|
12
|
+
* `resolveErrorTargets`; optional so targets built by hand still type.
|
|
13
|
+
*/
|
|
14
|
+
focusKey?: string;
|
|
16
15
|
/** The server's messages for that key, as sent. */
|
|
17
16
|
messages: string[];
|
|
18
17
|
/**
|
|
@@ -55,6 +54,14 @@ export interface ResolveErrorTargetsOptions {
|
|
|
55
54
|
* humanised key, so a broken label can never hide an error.
|
|
56
55
|
*/
|
|
57
56
|
resolveLabel?: (field: FieldDefinition, labelModel: Record<string, any>) => string | null | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The rows a failed submit's errors named, as captured then
|
|
59
|
+
* (`SubmitFailure.failedRows`). Pass it when resolving that failure's
|
|
60
|
+
* errors after the form may have changed (the error summary does), so a
|
|
61
|
+
* row error keeps the name of the row that failed and points at where
|
|
62
|
+
* that row is now. Leave it out for the form's live errors.
|
|
63
|
+
*/
|
|
64
|
+
failedRows?: Record<string, FailedRow>;
|
|
58
65
|
}
|
|
59
66
|
/**
|
|
60
67
|
* `delivery_date` → "Delivery date"; `lines.0.price` → "Lines price (line 1)".
|
|
@@ -79,7 +86,10 @@ export declare function humaniseErrorKey(key: string): string;
|
|
|
79
86
|
* Labels: the owning field's label; for `field.N.sub` the sub-field's label
|
|
80
87
|
* (when the field defines `fields` and one has that key) or the field's own,
|
|
81
88
|
* plus "(line N+1)" — unless the field's `rowLabel` names that row, when
|
|
82
|
-
* the row's name is the whole label
|
|
89
|
+
* the row's name is the whole label. With `failedRows`, a row key's label
|
|
90
|
+
* reads the row as it was when the submit failed, and its `focusKey` and
|
|
91
|
+
* line number follow that row to where it is now (see `ownedTarget`); for
|
|
92
|
+
* any other nested key (a media map's
|
|
83
93
|
* `image_media.<uuid>`) the field's label. An unowned key is humanised.
|
|
84
94
|
*
|
|
85
95
|
* 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:01:22.201Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "0.47.
|
|
5
|
+
"version": "0.47.1"
|
|
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 any error under `<key>.` (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 any error under `<key>.` (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:01:22.256Z
|
|
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 any error under `<key>.` (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 any error under `<key>.` (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
|
|
@@ -744,6 +744,24 @@ function setValue(value: any): void {
|
|
|
744
744
|
props.form.clearError(errorKey.value);
|
|
745
745
|
}
|
|
746
746
|
|
|
747
|
+
/**
|
|
748
|
+
* `update` for the `span` and `value` slots (and so DXTable's `edit-span` /
|
|
749
|
+
* `edit-value`). Such a slot may draw rows of its own and write the whole
|
|
750
|
+
* array back, so besides the field's own error it clears every error under
|
|
751
|
+
* `<key>.` (`lines.3.price`), as the repeater's add and remove do: the rows
|
|
752
|
+
* may have moved, and a stale per-row error would stay in the summary under
|
|
753
|
+
* the wrong row. They come back on the next submit.
|
|
754
|
+
*/
|
|
755
|
+
function setValueFromSlot(value: any): void {
|
|
756
|
+
setValue(value);
|
|
757
|
+
const prefix = `${errorKey.value}.`;
|
|
758
|
+
for (const key of Object.keys(props.form.errors)) {
|
|
759
|
+
if (key.startsWith(prefix)) {
|
|
760
|
+
props.form.clearError(key);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
747
765
|
const fieldState = computed(() => props.form.getState(errorKey.value));
|
|
748
766
|
|
|
749
767
|
// A label function that throws falls back to the humanised key, as the
|
|
@@ -882,8 +900,13 @@ function setPasswordRevealed(revealed: boolean): void {
|
|
|
882
900
|
// v-for is keyed on `field.key`, which is stable across rows) and swaps in a
|
|
883
901
|
// fresh form per record. Without this, revealing row A's password leaves row
|
|
884
902
|
// B's — and the create form's — showing in clear text.
|
|
903
|
+
// Two sources, not one getter returning `[form, key]`: that array is new on
|
|
904
|
+
// every run, so the callback would fire on ANY change the getter read,
|
|
905
|
+
// including a new `field` object from a consumer that rebuilds its fields
|
|
906
|
+
// from form data on each edit, re-masking straight after `setRevealed(true)`
|
|
907
|
+
// or the first keystroke after the eye.
|
|
885
908
|
watch(
|
|
886
|
-
() =>
|
|
909
|
+
[() => props.form, () => props.field.key],
|
|
887
910
|
() => {
|
|
888
911
|
passwordRevealed.value = false;
|
|
889
912
|
},
|
|
@@ -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. */
|
|
@@ -1094,7 +1106,7 @@ async function focusErrorTarget(target: ErrorSummarySelection): Promise<void> {
|
|
|
1094
1106
|
}
|
|
1095
1107
|
const token = ++revealToken;
|
|
1096
1108
|
let element = await renderedErrorElement(
|
|
1097
|
-
target.errorKey ?? target.fieldKey,
|
|
1109
|
+
target.focusKey ?? target.errorKey ?? target.fieldKey,
|
|
1098
1110
|
target.fieldKey,
|
|
1099
1111
|
);
|
|
1100
1112
|
if (token !== revealToken) return;
|
|
@@ -75,6 +75,14 @@ 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`). Equal to `errorKey` for everything else.
|
|
82
|
+
* Always emitted; optional so a selection built by hand for DXForm's
|
|
83
|
+
* exposed `focusErrorTarget` still types (it then uses `errorKey`).
|
|
84
|
+
*/
|
|
85
|
+
focusKey?: string;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
interface Props {
|
|
@@ -171,6 +179,7 @@ const targets = computed<ErrorTarget[]>(() => {
|
|
|
171
179
|
isFieldVisible: props.isFieldVisible,
|
|
172
180
|
isTabVisible: props.isTabVisible,
|
|
173
181
|
resolveLabel: props.resolveLabel,
|
|
182
|
+
failedRows: failure.value.failedRows,
|
|
174
183
|
});
|
|
175
184
|
});
|
|
176
185
|
|
|
@@ -178,6 +187,7 @@ interface Row {
|
|
|
178
187
|
id: string;
|
|
179
188
|
text: string;
|
|
180
189
|
errorKey: string;
|
|
190
|
+
focusKey: string;
|
|
181
191
|
fieldKey: string | null;
|
|
182
192
|
tabKey: string | null;
|
|
183
193
|
}
|
|
@@ -188,6 +198,7 @@ const rows = computed<Row[]>(() =>
|
|
|
188
198
|
id: `${target.errorKey}#${index}`,
|
|
189
199
|
text,
|
|
190
200
|
errorKey: target.errorKey,
|
|
201
|
+
focusKey: target.focusKey ?? target.errorKey,
|
|
191
202
|
fieldKey: target.fieldKey,
|
|
192
203
|
tabKey: target.tabKey,
|
|
193
204
|
})),
|
|
@@ -227,6 +238,7 @@ function selectRow(row: Row): void {
|
|
|
227
238
|
fieldKey: row.fieldKey,
|
|
228
239
|
tabKey: row.tabKey,
|
|
229
240
|
errorKey: row.errorKey,
|
|
241
|
+
focusKey: row.focusKey,
|
|
230
242
|
});
|
|
231
243
|
}
|
|
232
244
|
</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,29 @@ 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, as they were when the submit failed, keyed
|
|
31
|
+
* by row path (`lines.2` for `lines.2.price`; every numeric segment of
|
|
32
|
+
* every error key gets one). Absent when no error key names a row that
|
|
33
|
+
* exists in the form data. The error summary uses it to keep naming
|
|
34
|
+
* and finding the row that failed after rows are removed or reordered.
|
|
35
|
+
*/
|
|
36
|
+
failedRows?: Record<string, FailedRow>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One row a failed submit's errors named (see `SubmitFailure.failedRows`). */
|
|
40
|
+
export interface FailedRow {
|
|
41
|
+
/**
|
|
42
|
+
* The row object itself (raw, not a reactive proxy), so its current
|
|
43
|
+
* index can be found by identity. A row array replaced wholesale (a
|
|
44
|
+
* reload) holds new objects, and the row then counts as gone.
|
|
45
|
+
*/
|
|
46
|
+
row: object;
|
|
47
|
+
/**
|
|
48
|
+
* A copy of the row taken when the submit failed, for labels: a row's
|
|
49
|
+
* name (`rowLabel`) reads it, so later edits do not rename the error.
|
|
50
|
+
*/
|
|
51
|
+
snapshot: Record<string, any>;
|
|
27
52
|
}
|
|
28
53
|
|
|
29
54
|
export interface FormSubmitOptions<TPayload = unknown, TResponse = unknown> {
|
|
@@ -222,6 +247,42 @@ function submitFailureFrom(error: unknown): SubmitFailure | null {
|
|
|
222
247
|
};
|
|
223
248
|
}
|
|
224
249
|
|
|
250
|
+
/**
|
|
251
|
+
* A copy of a row for `FailedRow.snapshot`: a deep clone when the row allows
|
|
252
|
+
* one, else a shallow copy (a row holding a function or a `File` proxy that
|
|
253
|
+
* `structuredClone` refuses still gets a name).
|
|
254
|
+
*/
|
|
255
|
+
function snapshotRow(row: object): Record<string, any> {
|
|
256
|
+
try {
|
|
257
|
+
return structuredClone(row) as Record<string, any>;
|
|
258
|
+
} catch {
|
|
259
|
+
return { ...(row as Record<string, any>) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The `failedRows` for a failure's error keys, read from the form data as it
|
|
265
|
+
* is when the failure is recorded. Undefined when no key names a row.
|
|
266
|
+
*/
|
|
267
|
+
function failedRowsFor(
|
|
268
|
+
errors: ValidationErrors,
|
|
269
|
+
data: Record<string, any>,
|
|
270
|
+
): Record<string, FailedRow> | undefined {
|
|
271
|
+
const rows: Record<string, FailedRow> = {};
|
|
272
|
+
for (const errorKey of Object.keys(errors)) {
|
|
273
|
+
const segments = errorKey.split(".");
|
|
274
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
275
|
+
if (!/^\d+$/.test(segments[index])) continue;
|
|
276
|
+
const rowPath = segments.slice(0, index + 1).join(".");
|
|
277
|
+
if (rows[rowPath] !== undefined) continue;
|
|
278
|
+
const row = toRaw(getByPath(data, rowPath));
|
|
279
|
+
if (row === null || typeof row !== "object") continue;
|
|
280
|
+
rows[rowPath] = markRaw({ row, snapshot: snapshotRow(row) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return Object.keys(rows).length > 0 ? rows : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
225
286
|
function errorsFromLaravel(error: any): {
|
|
226
287
|
errors: ValidationErrors;
|
|
227
288
|
message: string;
|
|
@@ -523,6 +584,13 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
523
584
|
setErrors(errors);
|
|
524
585
|
setMessage(message);
|
|
525
586
|
if (failure) {
|
|
587
|
+
const failedRows = failedRowsFor(
|
|
588
|
+
failure.errors,
|
|
589
|
+
toRaw(state.data) as Record<string, any>,
|
|
590
|
+
);
|
|
591
|
+
if (failedRows !== undefined) {
|
|
592
|
+
failure.failedRows = failedRows;
|
|
593
|
+
}
|
|
526
594
|
state.submitFailure = failure;
|
|
527
595
|
state.failedSubmitCount += 1;
|
|
528
596
|
}
|
package/resources/js/index.ts
CHANGED
|
@@ -458,6 +458,8 @@ export interface BaseFieldDef {
|
|
|
458
458
|
plaintext?: MaybeFn<boolean>;
|
|
459
459
|
submit?: boolean;
|
|
460
460
|
errorKeys?: string[];
|
|
461
|
+
/** On a repeater, or a span field that renders its own rows. */
|
|
462
|
+
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
461
463
|
when?: MaybeFn<boolean>;
|
|
462
464
|
show?: () => boolean;
|
|
463
465
|
}
|
|
@@ -597,7 +599,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
|
|
|
597
599
|
export interface RepeaterFieldDef extends BaseFieldDef {
|
|
598
600
|
type: "repeater";
|
|
599
601
|
fields?: FieldDef[];
|
|
600
|
-
rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
|
|
601
602
|
addLabel?: string;
|
|
602
603
|
minItems?: number;
|
|
603
604
|
maxItems?: number;
|
|
@@ -673,13 +674,16 @@ export interface FormTab {
|
|
|
673
674
|
|
|
674
675
|
/**
|
|
675
676
|
* Slot props of DXField's `password-actions` slot (DXForm:
|
|
676
|
-
* `password-actions(<key>)`): content placed beside a password input,
|
|
677
|
-
*
|
|
678
|
-
*
|
|
677
|
+
* `password-actions(<key>)`): content placed beside a password input, such as
|
|
678
|
+
* a "Generate password" button. It renders after the input group (the input
|
|
679
|
+
* and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
|
|
680
|
+
* onto its own line below the input when the row is too narrow to keep
|
|
681
|
+
* `--dx-password-input-min-width`. `setRevealed` drives the same state the
|
|
682
|
+
* eye toggles.
|
|
679
683
|
*
|
|
680
684
|
* ```vue
|
|
681
685
|
* <template #password-actions(password)="{ update, setRevealed, disabled }">
|
|
682
|
-
* <DButton variant="
|
|
686
|
+
* <DButton variant="secondary" :disabled="disabled"
|
|
683
687
|
* @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
|
|
684
688
|
* </template>
|
|
685
689
|
* ```
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* `when`/`context`-aware predicates and a standalone summary can use the
|
|
8
8
|
* defaults below.
|
|
9
9
|
*/
|
|
10
|
+
import { toRaw } from "vue";
|
|
10
11
|
import type { FieldDefinition, FormTab } from "../types";
|
|
11
|
-
import type { ValidationErrors } from "../composables/useForm";
|
|
12
|
+
import type { FailedRow, ValidationErrors } from "../composables/useForm";
|
|
12
13
|
import {
|
|
13
14
|
isFieldVisible as isFieldVisibleFor,
|
|
14
15
|
resolvePredicate,
|
|
@@ -19,6 +20,14 @@ import { getByPath } from "./objectPath";
|
|
|
19
20
|
export interface ErrorTarget {
|
|
20
21
|
/** The key as the server sent it (`lines.0.price`). */
|
|
21
22
|
errorKey: string;
|
|
23
|
+
/**
|
|
24
|
+
* Where to take the user for this error now. The same as `errorKey`,
|
|
25
|
+
* except for a row error resolved with `failedRows`: the key at the
|
|
26
|
+
* failed row's CURRENT index (`lines.1.price` once row 0 is removed),
|
|
27
|
+
* or the owning field's key when that row is gone. Always set by
|
|
28
|
+
* `resolveErrorTargets`; optional so targets built by hand still type.
|
|
29
|
+
*/
|
|
30
|
+
focusKey?: string;
|
|
22
31
|
/** The server's messages for that key, as sent. */
|
|
23
32
|
messages: string[];
|
|
24
33
|
/**
|
|
@@ -65,6 +74,14 @@ export interface ResolveErrorTargetsOptions {
|
|
|
65
74
|
field: FieldDefinition,
|
|
66
75
|
labelModel: Record<string, any>,
|
|
67
76
|
) => string | null | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* The rows a failed submit's errors named, as captured then
|
|
79
|
+
* (`SubmitFailure.failedRows`). Pass it when resolving that failure's
|
|
80
|
+
* errors after the form may have changed (the error summary does), so a
|
|
81
|
+
* row error keeps the name of the row that failed and points at where
|
|
82
|
+
* that row is now. Leave it out for the form's live errors.
|
|
83
|
+
*/
|
|
84
|
+
failedRows?: Record<string, FailedRow>;
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
interface Candidate {
|
|
@@ -175,7 +192,10 @@ function rowLabelFor(
|
|
|
175
192
|
* Labels: the owning field's label; for `field.N.sub` the sub-field's label
|
|
176
193
|
* (when the field defines `fields` and one has that key) or the field's own,
|
|
177
194
|
* plus "(line N+1)" — unless the field's `rowLabel` names that row, when
|
|
178
|
-
* the row's name is the whole label
|
|
195
|
+
* the row's name is the whole label. With `failedRows`, a row key's label
|
|
196
|
+
* reads the row as it was when the submit failed, and its `focusKey` and
|
|
197
|
+
* line number follow that row to where it is now (see `ownedTarget`); for
|
|
198
|
+
* any other nested key (a media map's
|
|
179
199
|
* `image_media.<uuid>`) the field's label. An unowned key is humanised.
|
|
180
200
|
*
|
|
181
201
|
* Display lines: "Label: message", except that the label is left off when
|
|
@@ -266,22 +286,68 @@ export function resolveErrorTargets(
|
|
|
266
286
|
return longest;
|
|
267
287
|
};
|
|
268
288
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Where a failed row is now: its index in the field's live rows, found by
|
|
291
|
+
* identity, or -1 when it is gone (removed, or the rows were replaced).
|
|
292
|
+
*/
|
|
293
|
+
const currentIndexOf = (field: FieldDefinition, failedRow: FailedRow): number => {
|
|
294
|
+
const liveRows = getByPath(model, field.key);
|
|
295
|
+
if (!Array.isArray(liveRows)) return -1;
|
|
296
|
+
return liveRows.findIndex((row) => toRaw(row) === failedRow.row);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The label and focus key for an owned error key. For a row key
|
|
301
|
+
* (`field.N.sub`) with a captured failed row, both follow that row: the
|
|
302
|
+
* label is read from the row as it was when the submit failed, and the
|
|
303
|
+
* focus key uses the row's current index, or is the field's key when the
|
|
304
|
+
* row is gone. "(line N)" then counts the row's CURRENT position while it
|
|
305
|
+
* exists (the line a click takes the user to), and the position it failed
|
|
306
|
+
* at once it is gone.
|
|
307
|
+
*/
|
|
308
|
+
const ownedTarget = (
|
|
309
|
+
errorKey: string,
|
|
310
|
+
field: FieldDefinition,
|
|
311
|
+
): { label: string; focusKey: string } => {
|
|
312
|
+
if (!isNestedUnder(errorKey, field.key)) {
|
|
313
|
+
return { label: labelFor(field), focusKey: errorKey };
|
|
314
|
+
}
|
|
315
|
+
const [first, second, ...deeper] = errorKey
|
|
316
|
+
.slice(field.key.length + 1)
|
|
317
|
+
.split(".");
|
|
318
|
+
if (!NUMERIC_SEGMENT.test(first)) {
|
|
319
|
+
return { label: labelFor(field), focusKey: errorKey };
|
|
320
|
+
}
|
|
321
|
+
const failedIndex = Number(first);
|
|
273
322
|
const subField =
|
|
274
323
|
second !== undefined
|
|
275
324
|
? field.fields?.find((candidate) => candidate.key === second)
|
|
276
325
|
: undefined;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
326
|
+
const failedRow = options.failedRows?.[`${field.key}.${first}`];
|
|
327
|
+
|
|
328
|
+
let rowModel: Record<string, any>;
|
|
329
|
+
let lineIndex = failedIndex;
|
|
330
|
+
let focusKey = errorKey;
|
|
331
|
+
if (failedRow !== undefined) {
|
|
332
|
+
rowModel = failedRow.snapshot;
|
|
333
|
+
const currentIndex = currentIndexOf(field, failedRow);
|
|
334
|
+
if (currentIndex >= 0) {
|
|
335
|
+
lineIndex = currentIndex;
|
|
336
|
+
const rest = second !== undefined ? [second, ...deeper] : [];
|
|
337
|
+
focusKey = [field.key, String(currentIndex), ...rest].join(".");
|
|
338
|
+
} else {
|
|
339
|
+
focusKey = field.key;
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
// DXRepeater renders a sub-field with the ROW as its model, so its
|
|
343
|
+
// label resolves against the row here too.
|
|
344
|
+
const row = getByPath(model, `${field.key}.${first}`);
|
|
345
|
+
rowModel = row !== null && typeof row === "object" ? row : {};
|
|
346
|
+
}
|
|
347
|
+
const rowName = rowLabelFor(field, rowModel, failedIndex);
|
|
348
|
+
if (rowName !== null) return { label: rowName, focusKey };
|
|
283
349
|
const base = subField ? labelFor(subField, rowModel) : labelFor(field);
|
|
284
|
-
return `${base} (line ${
|
|
350
|
+
return { label: `${base} (line ${lineIndex + 1})`, focusKey };
|
|
285
351
|
};
|
|
286
352
|
|
|
287
353
|
// ——— one target per error key with at least one message
|
|
@@ -298,15 +364,15 @@ export function resolveErrorTargets(
|
|
|
298
364
|
);
|
|
299
365
|
if (messages.length === 0) return;
|
|
300
366
|
const owner = ownerOf(errorKey);
|
|
367
|
+
const owned = owner ? ownedTarget(errorKey, owner.field) : null;
|
|
301
368
|
pending.push({
|
|
302
369
|
target: {
|
|
303
370
|
errorKey,
|
|
371
|
+
focusKey: owned ? owned.focusKey : errorKey,
|
|
304
372
|
messages,
|
|
305
373
|
fieldKey: owner ? owner.field.key : null,
|
|
306
374
|
tabKey: owner ? owner.tabKey : null,
|
|
307
|
-
label:
|
|
308
|
-
? ownedLabel(errorKey, owner.field)
|
|
309
|
-
: humaniseErrorKey(errorKey),
|
|
375
|
+
label: owned ? owned.label : humaniseErrorKey(errorKey),
|
|
310
376
|
},
|
|
311
377
|
rank: owner ? owner.rank : [UNOWNED_RANK, UNOWNED_RANK],
|
|
312
378
|
order,
|