@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.
@@ -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 `repeater-row` slots for per-field customisation.
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="setValue"
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="setValue"
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="setValue"
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
- <DInputGroup v-else-if="isRevealablePassword">
341
- <DFormInput
342
- v-model="textFieldModel"
343
- :required="field.required"
344
- :placeholder="field.placeholder"
345
- :state="fieldState"
346
- :disabled="isDisabled"
347
- :readonly="isReadonly"
348
- v-bind="passwordInputProps"
349
- />
350
- <template #append>
351
- <DButton
352
- variant="outline-secondary"
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
- @click="passwordRevealed = !passwordRevealed"
354
+ :readonly="isReadonly"
355
+ v-bind="passwordInputProps"
358
356
  />
359
- </template>
360
- </DInputGroup>
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
- () => [props.form, props.field.key],
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: passwordRevealed.value ? "text" : "password",
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 { UseFormReturn, ValidationErrors } from "../../composables/useForm";
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(errors: ValidationErrors | null | undefined): ErrorTarget[] {
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
- /** Targets for what the last failed submit returned (the summary's rows). */
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` slot forwarding.
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 forwarded through to it
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`/`tab-*` slots onto
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
- const nonSubmittedFieldKeys = computed(
211
- () =>
212
- new Set(
213
- (props.editFields ?? [])
214
- .filter(
215
- (field) =>
216
- // Presentational: lays the form out, holds no data (#110).
217
- !isSubmittableField(field) ||
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
- const formData: Record<string, any> = {};
269
- submittableEditFields.value.forEach(field => {
270
- formData[field.key] = seedValueFor(field, item);
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
- const formData: Record<string, any> = {};
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
  };
@@ -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 {