@omnitend/dashboard-for-laravel 0.47.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -359,6 +359,8 @@ export interface BaseFieldDef {
359
359
  plaintext?: MaybeFn<boolean>;
360
360
  submit?: boolean;
361
361
  errorKeys?: string[];
362
+ /** On a repeater, or a span field that renders its own rows. */
363
+ rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
362
364
  when?: MaybeFn<boolean>;
363
365
  show?: () => boolean;
364
366
  }
@@ -461,7 +463,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
461
463
  export interface RepeaterFieldDef extends BaseFieldDef {
462
464
  type: "repeater";
463
465
  fields?: FieldDef[];
464
- rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
465
466
  addLabel?: string;
466
467
  minItems?: number;
467
468
  maxItems?: number;
@@ -508,13 +509,16 @@ export interface FormTab {
508
509
  }
509
510
  /**
510
511
  * Slot props of DXField's `password-actions` slot (DXForm:
511
- * `password-actions(<key>)`): content placed beside a password input, 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.
512
+ * `password-actions(<key>)`): content placed beside a password input, such as
513
+ * a "Generate password" button. It renders after the input group (the input
514
+ * and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
515
+ * onto its own line below the input when the row is too narrow to keep
516
+ * `--dx-password-input-min-width`. `setRevealed` drives the same state the
517
+ * eye toggles.
514
518
  *
515
519
  * ```vue
516
520
  * <template #password-actions(password)="{ update, setRevealed, disabled }">
517
- * <DButton variant="outline-secondary" :disabled="disabled"
521
+ * <DButton variant="secondary" :disabled="disabled"
518
522
  * @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
519
523
  * </template>
520
524
  * ```
@@ -1,18 +1,17 @@
1
- /**
2
- * Which field a validation-error key belongs to (#194).
3
- *
4
- * One pure function decides, so the error summary's rows, the tab a failed
5
- * submit selects and the field it scrolls to can never disagree. No Vue: the
6
- * caller injects visibility and label resolution, so DXForm can pass its own
7
- * `when`/`context`-aware predicates and a standalone summary can use the
8
- * defaults below.
9
- */
10
1
  import type { FieldDefinition, FormTab } from "../types";
11
- import type { ValidationErrors } from "../composables/useForm";
2
+ import type { FailedRow, ValidationErrors } from "../composables/useForm";
12
3
  /** One error key and where it belongs. */
13
4
  export interface ErrorTarget {
14
5
  /** The key as the server sent it (`lines.0.price`). */
15
6
  errorKey: string;
7
+ /**
8
+ * Where to take the user for this error now. The same as `errorKey`,
9
+ * except for a row error resolved with `failedRows`: the key at the
10
+ * failed row's CURRENT index (`lines.1.price` once row 0 is removed),
11
+ * or the owning field's key when that row is gone. Always set by
12
+ * `resolveErrorTargets`; optional so targets built by hand still type.
13
+ */
14
+ focusKey?: string;
16
15
  /** The server's messages for that key, as sent. */
17
16
  messages: string[];
18
17
  /**
@@ -55,6 +54,14 @@ export interface ResolveErrorTargetsOptions {
55
54
  * humanised key, so a broken label can never hide an error.
56
55
  */
57
56
  resolveLabel?: (field: FieldDefinition, labelModel: Record<string, any>) => string | null | undefined;
57
+ /**
58
+ * The rows a failed submit's errors named, as captured then
59
+ * (`SubmitFailure.failedRows`). Pass it when resolving that failure's
60
+ * errors after the form may have changed (the error summary does), so a
61
+ * row error keeps the name of the row that failed and points at where
62
+ * that row is now. Leave it out for the form's live errors.
63
+ */
64
+ failedRows?: Record<string, FailedRow>;
58
65
  }
59
66
  /**
60
67
  * `delivery_date` → "Delivery date"; `lines.0.price` → "Lines price (line 1)".
@@ -79,7 +86,10 @@ export declare function humaniseErrorKey(key: string): string;
79
86
  * Labels: the owning field's label; for `field.N.sub` the sub-field's label
80
87
  * (when the field defines `fields` and one has that key) or the field's own,
81
88
  * plus "(line N+1)" — unless the field's `rowLabel` names that row, when
82
- * the row's name is the whole label; for any other nested key (a media map's
89
+ * the row's name is the whole label. With `failedRows`, a row key's label
90
+ * reads the row as it was when the submit failed, and its `focusKey` and
91
+ * line number follow that row to where it is now (see `ownedTarget`); for
92
+ * any other nested key (a media map's
83
93
  * `image_media.<uuid>`) the field's label. An unowned key is humanised.
84
94
  *
85
95
  * Display lines: "Label: message", except that the label is left off when
@@ -1,8 +1,8 @@
1
1
  {
2
- "generated": "2026-10-21T10:08:54.451Z",
2
+ "generated": "2026-10-21T13:01:22.201Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
- "version": "0.47.0"
5
+ "version": "0.47.1"
6
6
  },
7
7
  "components": {
8
8
  "base": [
@@ -2205,7 +2205,7 @@
2205
2205
  "name": "(value: any) => void"
2206
2206
  },
2207
2207
  "name": "update",
2208
- "description": "Setter that writes the value back and clears the field's validation error."
2208
+ "description": "Setter that writes the value back and clears the field's validation error, and any error under `<key>.` (rows the slot draws itself)."
2209
2209
  }
2210
2210
  ]
2211
2211
  },
@@ -2287,7 +2287,7 @@
2287
2287
  "name": "(value: any) => void"
2288
2288
  },
2289
2289
  "name": "update",
2290
- "description": "Setter that writes the value back and clears the field's validation error."
2290
+ "description": "Setter that writes the value back and clears the field's validation error, and any error under `<key>.` (rows the slot draws itself)."
2291
2291
  }
2292
2292
  ]
2293
2293
  },
@@ -4433,7 +4433,7 @@
4433
4433
  },
4434
4434
  {
4435
4435
  "name": "`edit-password-actions(${key})`",
4436
- "description": "Extra controls beside password field `<key>`'s input in the edit/create modal, 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:01:22.256Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.47.0",
3
+ "version": "0.47.1",
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 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
@@ -744,6 +744,24 @@ function setValue(value: any): void {
744
744
  props.form.clearError(errorKey.value);
745
745
  }
746
746
 
747
+ /**
748
+ * `update` for the `span` and `value` slots (and so DXTable's `edit-span` /
749
+ * `edit-value`). Such a slot may draw rows of its own and write the whole
750
+ * array back, so besides the field's own error it clears every error under
751
+ * `<key>.` (`lines.3.price`), as the repeater's add and remove do: the rows
752
+ * may have moved, and a stale per-row error would stay in the summary under
753
+ * the wrong row. They come back on the next submit.
754
+ */
755
+ function setValueFromSlot(value: any): void {
756
+ setValue(value);
757
+ const prefix = `${errorKey.value}.`;
758
+ for (const key of Object.keys(props.form.errors)) {
759
+ if (key.startsWith(prefix)) {
760
+ props.form.clearError(key);
761
+ }
762
+ }
763
+ }
764
+
747
765
  const fieldState = computed(() => props.form.getState(errorKey.value));
748
766
 
749
767
  // A label function that throws falls back to the humanised key, as the
@@ -882,8 +900,13 @@ function setPasswordRevealed(revealed: boolean): void {
882
900
  // v-for is keyed on `field.key`, which is stable across rows) and swaps in a
883
901
  // fresh form per record. Without this, revealing row A's password leaves row
884
902
  // B's — and the create form's — showing in clear text.
903
+ // Two sources, not one getter returning `[form, key]`: that array is new on
904
+ // every run, so the callback would fire on ANY change the getter read,
905
+ // including a new `field` object from a consumer that rebuilds its fields
906
+ // from form data on each edit, re-masking straight after `setRevealed(true)`
907
+ // or the first keystroke after the eye.
885
908
  watch(
886
- () => [props.form, props.field.key],
909
+ [() => props.form, () => props.field.key],
887
910
  () => {
888
911
  passwordRevealed.value = false;
889
912
  },
@@ -227,7 +227,11 @@ import DXFormField from "./DXFormField.vue";
227
227
  import DXSaveButton from "./DXSaveButton.vue";
228
228
  import DXFormErrorSummary, { type ErrorSummarySelection } from "./DXFormErrorSummary.vue";
229
229
  import { resolveErrorTargets, type ErrorTarget } from "../../utils/formErrorTargets";
230
- import type { 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>
@@ -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,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
  }
@@ -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 {
@@ -458,6 +458,8 @@ export interface BaseFieldDef {
458
458
  plaintext?: MaybeFn<boolean>;
459
459
  submit?: boolean;
460
460
  errorKeys?: string[];
461
+ /** On a repeater, or a span field that renders its own rows. */
462
+ rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
461
463
  when?: MaybeFn<boolean>;
462
464
  show?: () => boolean;
463
465
  }
@@ -597,7 +599,6 @@ export interface ComponentFieldDef extends BaseFieldDef {
597
599
  export interface RepeaterFieldDef extends BaseFieldDef {
598
600
  type: "repeater";
599
601
  fields?: FieldDef[];
600
- rowLabel?: (row: Record<string, any>, index: number) => string | null | undefined;
601
602
  addLabel?: string;
602
603
  minItems?: number;
603
604
  maxItems?: number;
@@ -673,13 +674,16 @@ export interface FormTab {
673
674
 
674
675
  /**
675
676
  * Slot props of DXField's `password-actions` slot (DXForm:
676
- * `password-actions(<key>)`): content placed beside a password input, 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.
677
+ * `password-actions(<key>)`): content placed beside a password input, such as
678
+ * a "Generate password" button. It renders after the input group (the input
679
+ * and the reveal (eye) toggle), in `.dx-password-field__actions`, and wraps
680
+ * onto its own line below the input when the row is too narrow to keep
681
+ * `--dx-password-input-min-width`. `setRevealed` drives the same state the
682
+ * eye toggles.
679
683
  *
680
684
  * ```vue
681
685
  * <template #password-actions(password)="{ update, setRevealed, disabled }">
682
- * <DButton variant="outline-secondary" :disabled="disabled"
686
+ * <DButton variant="secondary" :disabled="disabled"
683
687
  * @click="update(generatePassword()); setRevealed(true)">Generate</DButton>
684
688
  * </template>
685
689
  * ```
@@ -7,8 +7,9 @@
7
7
  * `when`/`context`-aware predicates and a standalone summary can use the
8
8
  * defaults below.
9
9
  */
10
+ import { toRaw } from "vue";
10
11
  import type { FieldDefinition, FormTab } from "../types";
11
- import type { ValidationErrors } from "../composables/useForm";
12
+ import type { FailedRow, ValidationErrors } from "../composables/useForm";
12
13
  import {
13
14
  isFieldVisible as isFieldVisibleFor,
14
15
  resolvePredicate,
@@ -19,6 +20,14 @@ import { getByPath } from "./objectPath";
19
20
  export interface ErrorTarget {
20
21
  /** The key as the server sent it (`lines.0.price`). */
21
22
  errorKey: string;
23
+ /**
24
+ * Where to take the user for this error now. The same as `errorKey`,
25
+ * except for a row error resolved with `failedRows`: the key at the
26
+ * failed row's CURRENT index (`lines.1.price` once row 0 is removed),
27
+ * or the owning field's key when that row is gone. Always set by
28
+ * `resolveErrorTargets`; optional so targets built by hand still type.
29
+ */
30
+ focusKey?: string;
22
31
  /** The server's messages for that key, as sent. */
23
32
  messages: string[];
24
33
  /**
@@ -65,6 +74,14 @@ export interface ResolveErrorTargetsOptions {
65
74
  field: FieldDefinition,
66
75
  labelModel: Record<string, any>,
67
76
  ) => string | null | undefined;
77
+ /**
78
+ * The rows a failed submit's errors named, as captured then
79
+ * (`SubmitFailure.failedRows`). Pass it when resolving that failure's
80
+ * errors after the form may have changed (the error summary does), so a
81
+ * row error keeps the name of the row that failed and points at where
82
+ * that row is now. Leave it out for the form's live errors.
83
+ */
84
+ failedRows?: Record<string, FailedRow>;
68
85
  }
69
86
 
70
87
  interface Candidate {
@@ -175,7 +192,10 @@ function rowLabelFor(
175
192
  * Labels: the owning field's label; for `field.N.sub` the sub-field's label
176
193
  * (when the field defines `fields` and one has that key) or the field's own,
177
194
  * plus "(line N+1)" — unless the field's `rowLabel` names that row, when
178
- * the row's name is the whole label; for any other nested key (a media map's
195
+ * the row's name is the whole label. With `failedRows`, a row key's label
196
+ * reads the row as it was when the submit failed, and its `focusKey` and
197
+ * line number follow that row to where it is now (see `ownedTarget`); for
198
+ * any other nested key (a media map's
179
199
  * `image_media.<uuid>`) the field's label. An unowned key is humanised.
180
200
  *
181
201
  * Display lines: "Label: message", except that the label is left off when
@@ -266,22 +286,68 @@ export function resolveErrorTargets(
266
286
  return longest;
267
287
  };
268
288
 
269
- const ownedLabel = (errorKey: string, field: FieldDefinition): string => {
270
- if (!isNestedUnder(errorKey, field.key)) return labelFor(field);
271
- const [first, second] = errorKey.slice(field.key.length + 1).split(".");
272
- if (!NUMERIC_SEGMENT.test(first)) return labelFor(field);
289
+ /**
290
+ * Where a failed row is now: its index in the field's live rows, found by
291
+ * identity, or -1 when it is gone (removed, or the rows were replaced).
292
+ */
293
+ const currentIndexOf = (field: FieldDefinition, failedRow: FailedRow): number => {
294
+ const liveRows = getByPath(model, field.key);
295
+ if (!Array.isArray(liveRows)) return -1;
296
+ return liveRows.findIndex((row) => toRaw(row) === failedRow.row);
297
+ };
298
+
299
+ /**
300
+ * The label and focus key for an owned error key. For a row key
301
+ * (`field.N.sub`) with a captured failed row, both follow that row: the
302
+ * label is read from the row as it was when the submit failed, and the
303
+ * focus key uses the row's current index, or is the field's key when the
304
+ * row is gone. "(line N)" then counts the row's CURRENT position while it
305
+ * exists (the line a click takes the user to), and the position it failed
306
+ * at once it is gone.
307
+ */
308
+ const ownedTarget = (
309
+ errorKey: string,
310
+ field: FieldDefinition,
311
+ ): { label: string; focusKey: string } => {
312
+ if (!isNestedUnder(errorKey, field.key)) {
313
+ return { label: labelFor(field), focusKey: errorKey };
314
+ }
315
+ const [first, second, ...deeper] = errorKey
316
+ .slice(field.key.length + 1)
317
+ .split(".");
318
+ if (!NUMERIC_SEGMENT.test(first)) {
319
+ return { label: labelFor(field), focusKey: errorKey };
320
+ }
321
+ const failedIndex = Number(first);
273
322
  const subField =
274
323
  second !== undefined
275
324
  ? field.fields?.find((candidate) => candidate.key === second)
276
325
  : undefined;
277
- // DXRepeater renders a sub-field with the ROW as its model, so its
278
- // label resolves against the row here too.
279
- const row = getByPath(model, `${field.key}.${first}`);
280
- const rowModel = row !== null && typeof row === "object" ? row : {};
281
- const rowName = rowLabelFor(field, rowModel, Number(first));
282
- if (rowName !== null) return rowName;
326
+ const failedRow = options.failedRows?.[`${field.key}.${first}`];
327
+
328
+ let rowModel: Record<string, any>;
329
+ let lineIndex = failedIndex;
330
+ let focusKey = errorKey;
331
+ if (failedRow !== undefined) {
332
+ rowModel = failedRow.snapshot;
333
+ const currentIndex = currentIndexOf(field, failedRow);
334
+ if (currentIndex >= 0) {
335
+ lineIndex = currentIndex;
336
+ const rest = second !== undefined ? [second, ...deeper] : [];
337
+ focusKey = [field.key, String(currentIndex), ...rest].join(".");
338
+ } else {
339
+ focusKey = field.key;
340
+ }
341
+ } else {
342
+ // DXRepeater renders a sub-field with the ROW as its model, so its
343
+ // label resolves against the row here too.
344
+ const row = getByPath(model, `${field.key}.${first}`);
345
+ rowModel = row !== null && typeof row === "object" ? row : {};
346
+ }
347
+ const rowName = rowLabelFor(field, rowModel, failedIndex);
348
+ if (rowName !== null) return { label: rowName, focusKey };
283
349
  const base = subField ? labelFor(subField, rowModel) : labelFor(field);
284
- return `${base} (line ${Number(first) + 1})`;
350
+ return { label: `${base} (line ${lineIndex + 1})`, focusKey };
285
351
  };
286
352
 
287
353
  // ——— one target per error key with at least one message
@@ -298,15 +364,15 @@ export function resolveErrorTargets(
298
364
  );
299
365
  if (messages.length === 0) return;
300
366
  const owner = ownerOf(errorKey);
367
+ const owned = owner ? ownedTarget(errorKey, owner.field) : null;
301
368
  pending.push({
302
369
  target: {
303
370
  errorKey,
371
+ focusKey: owned ? owned.focusKey : errorKey,
304
372
  messages,
305
373
  fieldKey: owner ? owner.field.key : null,
306
374
  tabKey: owner ? owner.tabKey : null,
307
- label: owner
308
- ? ownedLabel(errorKey, owner.field)
309
- : humaniseErrorKey(errorKey),
375
+ label: owned ? owned.label : humaniseErrorKey(errorKey),
310
376
  },
311
377
  rank: owner ? owner.rank : [UNOWNED_RANK, UNOWNED_RANK],
312
378
  order,