@omnitend/dashboard-for-laravel 0.46.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 +28 -3
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +8 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +13 -3
- package/dist/composables/useForm.d.ts +22 -0
- package/dist/dashboard-for-laravel.js +9561 -9438
- 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 +2 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +64 -0
- package/dist/utils/formErrorTargets.d.ts +26 -12
- package/dist/utils/formSchema.d.ts +16 -0
- package/docs/public/api-reference.json +127 -7
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +75 -0
- package/resources/js/components/extended/DXField.vue +115 -29
- package/resources/js/components/extended/DXForm.vue +17 -5
- package/resources/js/components/extended/DXFormErrorSummary.vue +12 -0
- package/resources/js/components/extended/DXFormField.vue +3 -1
- package/resources/js/components/extended/DXTable.vue +4 -1
- package/resources/js/components/extended/DXTableEditorModal.vue +35 -2
- package/resources/js/composables/useForm.ts +68 -0
- package/resources/js/composables/useResourceEditor.ts +64 -26
- package/resources/js/index.ts +3 -0
- package/resources/js/types/index.ts +68 -0
- package/resources/js/utils/formErrorTargets.ts +107 -15
- package/resources/js/utils/formSchema.ts +27 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint` and `
|
|
3
|
+
Renders a single `FieldDefinition` of any type (text, select, autocomplete, checkbox, switch, repeater, currency, percentage, file/image, component, etc.) with its label, validation error, hint and help. Used by `DXForm` and `DXTable`'s edit modal; exposes `value`, `span`, `info`, `info-popover`, `hint`, `repeater-row` and `password-actions` slots for per-field customisation.
|
|
4
4
|
|
|
5
5
|
Structure (#135): a thin DISPATCHER. The shared chrome (label + validation
|
|
6
6
|
feedback + hint + help + info) lives in `DXFieldShell`; the numeric controls
|
|
@@ -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
|
|
@@ -336,28 +336,61 @@
|
|
|
336
336
|
<!-- Password with a reveal toggle (the default for
|
|
337
337
|
`type: "password"`). Typing a long password blind is the one
|
|
338
338
|
place the eye really earns its keep, so it's opt-out
|
|
339
|
-
(`revealable: false`) rather than opt-in.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
:icon="passwordRevealed ? 'eye-slash' : 'eye'"
|
|
354
|
-
:aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
|
|
355
|
-
:aria-pressed="passwordRevealed"
|
|
339
|
+
(`revealable: false`) rather than opt-in. The eye is drawn
|
|
340
|
+
as part of the field (`.dx-password-reveal`: the input's
|
|
341
|
+
border, radius and background). A `password-actions` slot
|
|
342
|
+
lands AFTER the input group, in `.dx-password-field__actions`,
|
|
343
|
+
which wraps below the group when the row cannot give the
|
|
344
|
+
input `--dx-password-input-min-width` (theme.scss); it also
|
|
345
|
+
brings the group to a `revealable: false` field. -->
|
|
346
|
+
<div v-else-if="isPasswordGroup()" class="dx-password-field">
|
|
347
|
+
<DInputGroup class="dx-password-field__group">
|
|
348
|
+
<DFormInput
|
|
349
|
+
v-model="textFieldModel"
|
|
350
|
+
:required="field.required"
|
|
351
|
+
:placeholder="field.placeholder"
|
|
352
|
+
:state="fieldState"
|
|
356
353
|
:disabled="isDisabled"
|
|
357
|
-
|
|
354
|
+
:readonly="isReadonly"
|
|
355
|
+
v-bind="passwordInputProps"
|
|
358
356
|
/>
|
|
359
|
-
|
|
360
|
-
|
|
357
|
+
<template #append>
|
|
358
|
+
<DButton
|
|
359
|
+
v-if="isRevealablePassword"
|
|
360
|
+
class="dx-password-reveal"
|
|
361
|
+
variant="outline-secondary"
|
|
362
|
+
icon-only
|
|
363
|
+
:icon="passwordRevealed ? 'eye-slash' : 'eye'"
|
|
364
|
+
:aria-label="passwordRevealed ? 'Hide password' : 'Show password'"
|
|
365
|
+
:aria-pressed="passwordRevealed"
|
|
366
|
+
:disabled="isDisabled"
|
|
367
|
+
@click="passwordRevealed = !passwordRevealed"
|
|
368
|
+
/>
|
|
369
|
+
</template>
|
|
370
|
+
</DInputGroup>
|
|
371
|
+
<div v-if="slots['password-actions'] !== undefined" class="dx-password-field__actions">
|
|
372
|
+
<!--
|
|
373
|
+
@slot Extra controls beside a password input, after its input group (the input and the reveal (eye) toggle), e.g. a "Generate password" button. They sit on the input's row while it keeps `--dx-password-input-min-width`, and wrap below it when the row is too narrow. Use an ordinary button (a `secondary` `DButton` matches the form's other buttons). Not rendered on a `plaintext` field.
|
|
374
|
+
@binding {FieldDefinition} field The field definition being rendered.
|
|
375
|
+
@binding {any} model The model passed to field predicates (defaults to the live form data).
|
|
376
|
+
@binding {any} value The current field value.
|
|
377
|
+
@binding {(value: any) => void} update Setter that writes the value back and clears the field's validation error.
|
|
378
|
+
@binding {boolean} revealed Whether the password is currently shown as plain text.
|
|
379
|
+
@binding {(revealed: boolean) => void} setRevealed Show (`true`) or mask (`false`) the password, exactly as the eye does. A no-op on a `revealable: false` field.
|
|
380
|
+
@binding {boolean} disabled Whether the field is disabled or read-only, so actions that change the value can disable themselves.
|
|
381
|
+
-->
|
|
382
|
+
<slot
|
|
383
|
+
name="password-actions"
|
|
384
|
+
:field="field"
|
|
385
|
+
:model="model"
|
|
386
|
+
:value="fieldValue"
|
|
387
|
+
:update="setValue"
|
|
388
|
+
:revealed="passwordRevealed"
|
|
389
|
+
:set-revealed="setPasswordRevealed"
|
|
390
|
+
:disabled="isDisabled || isReadonly"
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
361
394
|
|
|
362
395
|
<!-- Text-based inputs (text/email/password/url/tel/date/time/datetime) -->
|
|
363
396
|
<DFormInput
|
|
@@ -711,6 +744,24 @@ function setValue(value: any): void {
|
|
|
711
744
|
props.form.clearError(errorKey.value);
|
|
712
745
|
}
|
|
713
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
|
+
|
|
714
765
|
const fieldState = computed(() => props.form.getState(errorKey.value));
|
|
715
766
|
|
|
716
767
|
// A label function that throws falls back to the humanised key, as the
|
|
@@ -819,12 +870,43 @@ const isRevealablePassword = computed(
|
|
|
819
870
|
!isPlaintext.value,
|
|
820
871
|
);
|
|
821
872
|
|
|
873
|
+
/*
|
|
874
|
+
* The password input group: the eye (unless `revealable: false`) plus any
|
|
875
|
+
* `password-actions` slot content. A `revealable: false` field with no slot
|
|
876
|
+
* stays a bare input, as before; with the slot it gets the group so the
|
|
877
|
+
* actions have somewhere to sit, but still no eye and no way to unmask.
|
|
878
|
+
* A function, not a computed: `useSlots()` is not reactive, so a computed
|
|
879
|
+
* would keep the first render's answer if the slot came or went later.
|
|
880
|
+
*/
|
|
881
|
+
function isPasswordGroup(): boolean {
|
|
882
|
+
return (
|
|
883
|
+
isRevealablePassword.value ||
|
|
884
|
+
(props.field.type === "password" &&
|
|
885
|
+
!isPlaintext.value &&
|
|
886
|
+
slots["password-actions"] !== undefined)
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* The `password-actions` slot's handle on the reveal state: the same state
|
|
892
|
+
* the eye toggles, so the two never disagree. Refused on a `revealable: false`
|
|
893
|
+
* field, which promises never to unmask.
|
|
894
|
+
*/
|
|
895
|
+
function setPasswordRevealed(revealed: boolean): void {
|
|
896
|
+
passwordRevealed.value = isRevealablePassword.value && revealed === true;
|
|
897
|
+
}
|
|
898
|
+
|
|
822
899
|
// DXTable reuses ONE DXField instance per field key across modal opens (the
|
|
823
900
|
// v-for is keyed on `field.key`, which is stable across rows) and swaps in a
|
|
824
901
|
// fresh form per record. Without this, revealing row A's password leaves row
|
|
825
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.
|
|
826
908
|
watch(
|
|
827
|
-
() =>
|
|
909
|
+
[() => props.form, () => props.field.key],
|
|
828
910
|
() => {
|
|
829
911
|
passwordRevealed.value = false;
|
|
830
912
|
},
|
|
@@ -864,10 +946,14 @@ const controlPropsWithGuards = computed(() => {
|
|
|
864
946
|
return merged;
|
|
865
947
|
});
|
|
866
948
|
|
|
867
|
-
// The reveal toggle owns the password input's `type`.
|
|
949
|
+
// The reveal toggle owns the password input's `type`. Only a revealable field
|
|
950
|
+
// can be shown as text (`setPasswordRevealed` also refuses the others).
|
|
868
951
|
const passwordInputProps = computed(() => ({
|
|
869
952
|
...inputPropsWithGuards.value,
|
|
870
|
-
type:
|
|
953
|
+
type:
|
|
954
|
+
passwordRevealed.value && isRevealablePassword.value
|
|
955
|
+
? "text"
|
|
956
|
+
: "password",
|
|
871
957
|
}));
|
|
872
958
|
|
|
873
959
|
const inputType = computed<string>(() => {
|
|
@@ -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>
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
Owns the single per-field render block that DXForm's tabbed and flat layouts
|
|
6
6
|
both need: the `field(<key>)` full-replacement slot, the
|
|
7
7
|
`field-before(<key>)`/`field-after(<key>)` wrapper slots, and the `<DXField>`
|
|
8
|
-
element with its `value`/`span`/`info`/`hint`/`repeater-row
|
|
8
|
+
element with its `value`/`span`/`info`/`hint`/`repeater-row`/
|
|
9
|
+
`switch-list-item`/`password-actions` slot forwarding.
|
|
9
10
|
|
|
10
11
|
Extracted so those per-field props/slots are wired in ONE place — before this,
|
|
11
12
|
DXForm carried two near-identical copies (tabbed + flat) and a new per-field
|
|
@@ -99,6 +100,7 @@ function fieldSlotMap(): Record<string, string> {
|
|
|
99
100
|
["hint", `hint(${key})`],
|
|
100
101
|
["repeater-row", `repeater-row(${key})`],
|
|
101
102
|
["switch-list-item", `switch-list-item(${key})`],
|
|
103
|
+
["password-actions", `password-actions(${key})`],
|
|
102
104
|
];
|
|
103
105
|
for (const [target, source] of candidates) {
|
|
104
106
|
if (slots[source]) map[target] = source;
|
|
@@ -266,7 +266,8 @@
|
|
|
266
266
|
</DRow>
|
|
267
267
|
|
|
268
268
|
<!-- Edit/create modal (only when editFields provided). The consumer's
|
|
269
|
-
edit-value / edit-span / tab-* slots are
|
|
269
|
+
edit-value / edit-span / edit-password-actions / tab-* slots are
|
|
270
|
+
forwarded through to it
|
|
270
271
|
generically; it maps them onto DXForm and provides the row context
|
|
271
272
|
+ a close handle (#129). -->
|
|
272
273
|
<DXTableEditorModal
|
|
@@ -299,6 +300,7 @@
|
|
|
299
300
|
>
|
|
300
301
|
<!--
|
|
301
302
|
@slot edit-value(<fieldKey>) / edit-span(<fieldKey>) /
|
|
303
|
+
edit-password-actions(<fieldKey>) /
|
|
302
304
|
tab-content|tab-before|tab-after(<tabKey>) — forwarded to the edit
|
|
303
305
|
modal (see DXTableEditorModal for their bindings).
|
|
304
306
|
-->
|
|
@@ -2514,6 +2516,7 @@ const handleDelete = editor.remove;
|
|
|
2514
2516
|
const EDIT_MODAL_SLOT_PREFIXES = [
|
|
2515
2517
|
'edit-value(',
|
|
2516
2518
|
'edit-span(',
|
|
2519
|
+
'edit-password-actions(',
|
|
2517
2520
|
'tab-content(',
|
|
2518
2521
|
'tab-before(',
|
|
2519
2522
|
'tab-after(',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
3
|
Internal edit/create modal for `DXTable` (#129). Renders the `DModal` +
|
|
4
|
-
`DXForm` and maps DXTable's `edit-value`/`edit-span`/`
|
|
5
|
-
DXForm's `value`/`span`/`tab-*` slots, providing the row context and a `close`
|
|
4
|
+
`DXForm` and maps DXTable's `edit-value`/`edit-span`/`edit-password-actions`/
|
|
5
|
+
`tab-*` slots onto DXForm's `value`/`span`/`password-actions`/`tab-*` slots, providing the row context and a `close`
|
|
6
6
|
handle. Extracted so `DXTable` is a thin façade over the table render, the
|
|
7
7
|
pagination, the editor logic (`useResourceEditor`) and this modal — rather than
|
|
8
8
|
owning all four.
|
|
@@ -115,6 +115,36 @@
|
|
|
115
115
|
/>
|
|
116
116
|
</template>
|
|
117
117
|
|
|
118
|
+
<!-- Forward edit-password-actions(key) → password-actions(key):
|
|
119
|
+
content after the password input group (input and eye),
|
|
120
|
+
with a handle on dfl's own reveal (eye) state. -->
|
|
121
|
+
<template
|
|
122
|
+
v-for="key in editPasswordActionsSlotKeys"
|
|
123
|
+
:key="`epa-${key}`"
|
|
124
|
+
#[`password-actions(${key})`]="sp"
|
|
125
|
+
>
|
|
126
|
+
<!--
|
|
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
|
+
@binding {object} item The row being edited (null in create mode).
|
|
129
|
+
@binding {any} value The current field value.
|
|
130
|
+
@binding {Function} update Call with a new value to update the field.
|
|
131
|
+
@binding {boolean} revealed Whether the password is currently shown as plain text.
|
|
132
|
+
@binding {Function} setRevealed Call with true to show the password (as the eye does) or false to mask it.
|
|
133
|
+
@binding {boolean} disabled Whether the field is disabled or read-only.
|
|
134
|
+
@binding {object} field The field definition.
|
|
135
|
+
-->
|
|
136
|
+
<slot
|
|
137
|
+
:name="`edit-password-actions(${key})`"
|
|
138
|
+
:item="item"
|
|
139
|
+
:value="sp.value"
|
|
140
|
+
:update="sp.update"
|
|
141
|
+
:revealed="sp.revealed"
|
|
142
|
+
:set-revealed="sp.setRevealed"
|
|
143
|
+
:disabled="sp.disabled"
|
|
144
|
+
:field="sp.field"
|
|
145
|
+
/>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
118
148
|
<!-- Forward tab-content / tab-before / tab-after slots -->
|
|
119
149
|
<template
|
|
120
150
|
v-for="key in tabContentSlotKeys"
|
|
@@ -318,6 +348,9 @@ const editValueSlotKeys = computed(() =>
|
|
|
318
348
|
const editSpanSlotKeys = computed(() =>
|
|
319
349
|
fieldKeys.value.filter((key) => !!slots[`edit-span(${key})`]),
|
|
320
350
|
);
|
|
351
|
+
const editPasswordActionsSlotKeys = computed(() =>
|
|
352
|
+
fieldKeys.value.filter((key) => !!slots[`edit-password-actions(${key})`]),
|
|
353
|
+
);
|
|
321
354
|
const tabContentSlotKeys = computed(() =>
|
|
322
355
|
tabKeys.value.filter((key) => !!slots[`tab-content(${key})`]),
|
|
323
356
|
);
|
|
@@ -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
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resolveFieldDefault,
|
|
8
8
|
isFieldVisible as isFieldVisibleFor,
|
|
9
9
|
isSubmittableField,
|
|
10
|
+
nonSubmittedFieldKeys as nonSubmittedFieldKeysFor,
|
|
10
11
|
} from "../utils/formSchema";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -207,24 +208,67 @@ export function useResourceEditor<T = any>(
|
|
|
207
208
|
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
208
209
|
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
209
210
|
// its key back into the form data after seeding. Strip them on the way out.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// Hidden by `when` at submit time (#117). Submitting a field
|
|
219
|
-
// the user cannot see is a silent write — and with `default`
|
|
220
|
-
// set it writes a value they never chose. Omitting the key
|
|
221
|
-
// leaves the stored value alone.
|
|
222
|
-
!isEditFieldVisible(field),
|
|
223
|
-
)
|
|
224
|
-
.map((field) => field.key),
|
|
225
|
-
),
|
|
211
|
+
//
|
|
212
|
+
// Decided per KEY (#228): a key is dropped only when no field with that key
|
|
213
|
+
// is both submitting and visible at submit time. A presentational field
|
|
214
|
+
// (#110) holds no data, and a field hidden by `when` (#117) must not write a
|
|
215
|
+
// value the user cannot see — but a display-only twin sharing its key with
|
|
216
|
+
// the visible editable field must not strip the editable field's value.
|
|
217
|
+
const nonSubmittedFieldKeys = computed(() =>
|
|
218
|
+
nonSubmittedFieldKeysFor(props.editFields ?? [], isEditFieldVisible),
|
|
226
219
|
);
|
|
227
220
|
|
|
221
|
+
/*
|
|
222
|
+
* Seed the form's data, ONE value per key (#228). Presentational fields are
|
|
223
|
+
* never seeded. When several submitting fields share a key (alternative
|
|
224
|
+
* controls for one value, switched by `when`), the key is seeded from the
|
|
225
|
+
* first of them that is visible against the seeded model, else the first
|
|
226
|
+
* declared. This used to be last-declared-wins, so a hidden alternative
|
|
227
|
+
* declared after the visible field replaced its default.
|
|
228
|
+
*
|
|
229
|
+
* Two submitting fields with one key that are visible TOGETHER are almost
|
|
230
|
+
* certainly a mistake (two controls bound to one value): the first declared
|
|
231
|
+
* wins, and DXTable warns once per key.
|
|
232
|
+
*/
|
|
233
|
+
const warnedSharedVisibleKeys = new Set<string>();
|
|
234
|
+
const seedFormData = (
|
|
235
|
+
valueFor: (field: FieldDefinition) => any,
|
|
236
|
+
context: Record<string, any>,
|
|
237
|
+
): Record<string, any> => {
|
|
238
|
+
const fieldsByKey = new Map<string, FieldDefinition[]>();
|
|
239
|
+
submittableEditFields.value.forEach((field) => {
|
|
240
|
+
const sharing = fieldsByKey.get(field.key);
|
|
241
|
+
if (sharing) sharing.push(field);
|
|
242
|
+
else fieldsByKey.set(field.key, [field]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const formData: Record<string, any> = {};
|
|
246
|
+
fieldsByKey.forEach((fields, key) => {
|
|
247
|
+
formData[key] = valueFor(fields[0]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const seededModel = { ...context, ...formData };
|
|
251
|
+
fieldsByKey.forEach((fields, key) => {
|
|
252
|
+
if (fields.length < 2) return;
|
|
253
|
+
const visibleFields = fields.filter((field) =>
|
|
254
|
+
isFieldVisibleFor(field, seededModel),
|
|
255
|
+
);
|
|
256
|
+
if (visibleFields.length > 1 && !warnedSharedVisibleKeys.has(key)) {
|
|
257
|
+
warnedSharedVisibleKeys.add(key);
|
|
258
|
+
// eslint-disable-next-line no-console
|
|
259
|
+
console.warn(
|
|
260
|
+
`[DXTable] ${visibleFields.length} submitting edit fields share the key "${key}" and are visible together, ` +
|
|
261
|
+
'so they edit one value; the first declared seeds it. ' +
|
|
262
|
+
'Give alternatives mutually exclusive `when` predicates, or mark a display-only one `submit: false`.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (visibleFields.length > 0 && visibleFields[0] !== fields[0]) {
|
|
266
|
+
formData[key] = valueFor(visibleFields[0]);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
return formData;
|
|
270
|
+
};
|
|
271
|
+
|
|
228
272
|
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
229
273
|
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
230
274
|
return Object.fromEntries(
|
|
@@ -265,11 +309,9 @@ export function useResourceEditor<T = any>(
|
|
|
265
309
|
// between successive row opens. Presentational fields (submit: false)
|
|
266
310
|
// lay the form out; they hold no data and must not be POSTed just
|
|
267
311
|
// because they were declared.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
});
|
|
272
|
-
startSession(formData);
|
|
312
|
+
startSession(
|
|
313
|
+
seedFormData((field) => seedValueFor(field, item), item as any),
|
|
314
|
+
);
|
|
273
315
|
|
|
274
316
|
// Open modal
|
|
275
317
|
editFormInstanceKey.value++;
|
|
@@ -332,11 +374,7 @@ export function useResourceEditor<T = any>(
|
|
|
332
374
|
editLoading.value = false;
|
|
333
375
|
|
|
334
376
|
// A fresh form seeded with the field defaults (see `startSession`).
|
|
335
|
-
|
|
336
|
-
submittableEditFields.value.forEach(field => {
|
|
337
|
-
formData[field.key] = resolveFieldDefault(field);
|
|
338
|
-
});
|
|
339
|
-
startSession(formData);
|
|
377
|
+
startSession(seedFormData(resolveFieldDefault, {}));
|
|
340
378
|
editFormInstanceKey.value++;
|
|
341
379
|
showEditModal.value = true;
|
|
342
380
|
};
|
package/resources/js/index.ts
CHANGED
|
@@ -148,6 +148,8 @@ export type {
|
|
|
148
148
|
FormTab,
|
|
149
149
|
MaybeFn,
|
|
150
150
|
OptionsLoader,
|
|
151
|
+
PasswordActionsSlotProps,
|
|
152
|
+
EditPasswordActionsSlotProps,
|
|
151
153
|
// Strict, discriminated field types (#131) — opt-in; canonical in a future
|
|
152
154
|
// major. `FieldDef` is the union; the rest are its per-type members.
|
|
153
155
|
FieldDef,
|
|
@@ -178,6 +180,7 @@ export type {
|
|
|
178
180
|
FormState,
|
|
179
181
|
UseFormReturn,
|
|
180
182
|
SubmitFailure,
|
|
183
|
+
FailedRow,
|
|
181
184
|
} from "./composables/useForm";
|
|
182
185
|
|
|
183
186
|
export type {
|