@omnitend/dashboard-for-laravel 0.47.0 → 0.47.2

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