@omnitend/dashboard-for-laravel 0.43.0 → 0.45.0

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.
Files changed (40) hide show
  1. package/dist/components/base/DFormSelect.vue.d.ts +1 -1
  2. package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
  3. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  4. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  5. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  6. package/dist/components/extended/DXLoading.vue.d.ts +3 -0
  7. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  8. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  9. package/dist/composables/useAsyncOptions.d.ts +14 -1
  10. package/dist/composables/useForm.d.ts +26 -0
  11. package/dist/dashboard-for-laravel.js +13605 -13058
  12. package/dist/dashboard-for-laravel.js.map +1 -1
  13. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  14. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/style.css +1 -1
  17. package/dist/types/index.d.ts +12 -0
  18. package/dist/utils/formErrorTargets.d.ts +86 -0
  19. package/docs/public/api-reference.json +148 -8
  20. package/docs/public/docs-map.md +3 -2
  21. package/docs/public/llms.txt +3 -2
  22. package/package.json +9 -8
  23. package/resources/css/theme.scss +176 -0
  24. package/resources/js/components/base/DFormSelect.vue +5 -2
  25. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  26. package/resources/js/components/extended/DXField.vue +51 -17
  27. package/resources/js/components/extended/DXForm.vue +676 -28
  28. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  29. package/resources/js/components/extended/DXFormField.vue +11 -2
  30. package/resources/js/components/extended/DXLoading.vue +18 -11
  31. package/resources/js/components/extended/DXRepeater.vue +30 -4
  32. package/resources/js/components/extended/DXTable.vue +38 -9
  33. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  34. package/resources/js/composables/useAsyncOptions.ts +30 -5
  35. package/resources/js/composables/useForm.ts +196 -18
  36. package/resources/js/composables/useResourceEditor.ts +140 -107
  37. package/resources/js/index.ts +15 -0
  38. package/resources/js/types/index.ts +13 -0
  39. package/resources/js/utils/formErrorTargets.ts +317 -0
  40. package/scripts/mcp-server.mjs +58 -6
@@ -0,0 +1,232 @@
1
+ <!--
2
+ @component DXFormErrorSummary
3
+ Lists everything the last failed submit of a `useForm` returned: a heading,
4
+ then one row per validation message with a readable label ("Price (line 1):
5
+ Must be positive."). The heading is `title` ("Couldn't save. Please
6
+ check:") when the failure has field errors, and the server's message when
7
+ it has none (a message-only 422, a 500, a network failure), since then the
8
+ message is all there is. Built from `form.submitFailure`, so it survives
9
+ field edits and `clearError`, and disappears when the next submit starts or
10
+ one succeeds. Renders nothing before a failure. A row whose key belongs to a
11
+ visible field is a button that emits `select-target`, for the host to select
12
+ the field's tab and focus it; a key no visible field owns is listed as plain
13
+ text, after the owned rows, under `otherTitle` ("Other problems") when both
14
+ kinds are present. Place it next to the submit control.
15
+ -->
16
+ <template>
17
+ <!-- A real element owns the class: DAlert's root is a transition, so a
18
+ class on it is not a reliable host for the theme rules. BAlert
19
+ supplies role="alert" + aria-live="assertive"; keying it by the
20
+ failure count remounts it, so every new failure is announced once. -->
21
+ <div v-if="failure" class="dx-form-error-summary">
22
+ <DAlert :key="resolvedForm.failedSubmitCount" variant="danger">
23
+ <p class="dx-form-error-summary__message">{{ heading }}</p>
24
+ <!-- Rows a field owns (buttons that go to it), then rows no field
25
+ owns (text). The sub-heading only says "these are the rest"
26
+ when there is a first list for them to be the rest of. A
27
+ <p>, not a heading element: it must not enter the page
28
+ outline. -->
29
+ <template v-for="group in groups" :key="group.key">
30
+ <p
31
+ v-if="group.title !== null"
32
+ class="dx-form-error-summary__other-title"
33
+ >
34
+ {{ group.title }}
35
+ </p>
36
+ <ul class="dx-form-error-summary__list">
37
+ <li
38
+ v-for="row in group.rows"
39
+ :key="row.id"
40
+ class="dx-form-error-summary__item"
41
+ >
42
+ <button
43
+ v-if="row.fieldKey !== null"
44
+ type="button"
45
+ class="dx-form-error-summary__target"
46
+ :data-dx-error-key="row.errorKey"
47
+ @click="selectRow(row)"
48
+ >
49
+ {{ row.text }}
50
+ </button>
51
+ <span v-else :data-dx-error-key="row.errorKey">{{ row.text }}</span>
52
+ </li>
53
+ </ul>
54
+ </template>
55
+ </DAlert>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup lang="ts">
60
+ import { computed } from "vue";
61
+ import DAlert from "../base/DAlert.vue";
62
+ import type { UseFormReturn } from "../../composables/useForm";
63
+ import type { DefineFormReturn } from "../../composables/defineForm";
64
+ import type { FieldDefinition, FormTab } from "../../types";
65
+ import {
66
+ resolveErrorTargets,
67
+ type ErrorTarget,
68
+ } from "../../utils/formErrorTargets";
69
+
70
+ /** What a summary row points at (emitted by `select-target`). */
71
+ export interface ErrorSummarySelection {
72
+ /** The owning field's key. */
73
+ fieldKey: string;
74
+ /** The tab holding that field, or `null` in a flat form. */
75
+ tabKey: string | null;
76
+ /** The error key as the server sent it. */
77
+ errorKey: string;
78
+ }
79
+
80
+ interface Props {
81
+ /** The form: a `useForm` return or a `defineForm` return. */
82
+ form: UseFormReturn<any> | DefineFormReturn<any>;
83
+ /** Field definitions (optional when `form` is a defineForm return). */
84
+ fields?: FieldDefinition[];
85
+ /** The form's tabs, so rows follow tab order and know their tab. */
86
+ tabs?: FormTab[];
87
+ /**
88
+ * Extra context merged under the form data when evaluating `when` and
89
+ * function labels, as on DXForm.
90
+ */
91
+ context?: Record<string, any>;
92
+ /**
93
+ * Field visibility, replacing the default (`when`/`show` against form
94
+ * data + `context`). A host form passes its own predicate so the summary
95
+ * agrees with what it renders.
96
+ */
97
+ isFieldVisible?: (field: FieldDefinition) => boolean;
98
+ /** Tab visibility, replacing the default (the tab's `when`). */
99
+ isTabVisible?: (tab: FormTab) => boolean;
100
+ /**
101
+ * A field's label, replacing the default (its `label`, resolved against
102
+ * `labelModel`: the form model, or the row for a repeater sub-field).
103
+ */
104
+ resolveLabel?: (
105
+ field: FieldDefinition,
106
+ labelModel: Record<string, any>,
107
+ ) => string | null | undefined;
108
+ /**
109
+ * Rows already resolved by the host (from `resolveErrorTargets` over
110
+ * `form.submitFailure.errors`). When given, the summary lists these and
111
+ * resolves nothing itself; the message still comes from
112
+ * `form.submitFailure`, and nothing renders while that is null.
113
+ */
114
+ targets?: ErrorTarget[] | null;
115
+ /**
116
+ * The heading when the failure has at least one field error. The
117
+ * server's own message is not used then: Laravel's default 422 message
118
+ * is the first error plus "(and N more errors)", which repeats the first
119
+ * row. A failure with no field errors (a message-only 422, a 500, a
120
+ * network failure) keeps the server's message as the heading, as it is
121
+ * the only information.
122
+ */
123
+ title?: string;
124
+ /**
125
+ * The sub-heading over the rows no visible field owns, listed after the
126
+ * owned rows. Shown only when there are both kinds; when no row is owned
127
+ * they are listed under the heading alone.
128
+ */
129
+ otherTitle?: string;
130
+ }
131
+
132
+ const props = withDefaults(defineProps<Props>(), {
133
+ title: "Couldn't save. Please check:",
134
+ otherTitle: "Other problems",
135
+ });
136
+
137
+ const emit = defineEmits<{
138
+ /** A row was clicked: select `tabKey` (if any) and focus `fieldKey`. */
139
+ "select-target": [selection: ErrorSummarySelection];
140
+ }>();
141
+
142
+ function isDefineForm(value: Props["form"]): value is DefineFormReturn<any> {
143
+ return (
144
+ !!value &&
145
+ typeof value === "object" &&
146
+ "form" in value &&
147
+ "fields" in value &&
148
+ !("data" in value)
149
+ );
150
+ }
151
+
152
+ const resolvedForm = computed<UseFormReturn<any>>(() =>
153
+ isDefineForm(props.form) ? props.form.form : props.form,
154
+ );
155
+
156
+ const resolvedFields = computed<FieldDefinition[]>(() => {
157
+ if (props.fields) return props.fields;
158
+ if (isDefineForm(props.form)) return props.form.fields;
159
+ return [];
160
+ });
161
+
162
+ const failure = computed(() => resolvedForm.value.submitFailure ?? null);
163
+
164
+ const targets = computed<ErrorTarget[]>(() => {
165
+ if (!failure.value) return [];
166
+ if (props.targets) return props.targets;
167
+ return resolveErrorTargets(failure.value.errors, {
168
+ fields: resolvedFields.value,
169
+ tabs: props.tabs,
170
+ model: { ...(props.context ?? {}), ...resolvedForm.value.data },
171
+ isFieldVisible: props.isFieldVisible,
172
+ isTabVisible: props.isTabVisible,
173
+ resolveLabel: props.resolveLabel,
174
+ });
175
+ });
176
+
177
+ interface Row {
178
+ id: string;
179
+ text: string;
180
+ errorKey: string;
181
+ fieldKey: string | null;
182
+ tabKey: string | null;
183
+ }
184
+
185
+ const rows = computed<Row[]>(() =>
186
+ targets.value.flatMap((target) =>
187
+ target.lines.map((text, index) => ({
188
+ id: `${target.errorKey}#${index}`,
189
+ text,
190
+ errorKey: target.errorKey,
191
+ fieldKey: target.fieldKey,
192
+ tabKey: target.tabKey,
193
+ })),
194
+ ),
195
+ );
196
+
197
+ const heading = computed(() =>
198
+ rows.value.length > 0 ? props.title : (failure.value?.message ?? ""),
199
+ );
200
+
201
+ interface RowGroup {
202
+ key: "owned" | "other";
203
+ /** The sub-heading above the list, or `null` for none. */
204
+ title: string | null;
205
+ rows: Row[];
206
+ }
207
+
208
+ /** Owned rows, then unowned ones (labelled only when both are present). */
209
+ const groups = computed<RowGroup[]>(() => {
210
+ const owned = rows.value.filter((row) => row.fieldKey !== null);
211
+ const other = rows.value.filter((row) => row.fieldKey === null);
212
+ const result: RowGroup[] = [];
213
+ if (owned.length > 0) result.push({ key: "owned", title: null, rows: owned });
214
+ if (other.length > 0) {
215
+ result.push({
216
+ key: "other",
217
+ title: owned.length > 0 ? props.otherTitle : null,
218
+ rows: other,
219
+ });
220
+ }
221
+ return result;
222
+ });
223
+
224
+ function selectRow(row: Row): void {
225
+ if (row.fieldKey === null) return;
226
+ emit("select-target", {
227
+ fieldKey: row.fieldKey,
228
+ tabKey: row.tabKey,
229
+ errorKey: row.errorKey,
230
+ });
231
+ }
232
+ </script>
@@ -17,12 +17,18 @@
17
17
  -->
18
18
  <template>
19
19
  <!-- Full replacement slot bypasses DXField entirely (mirrors tab-content):
20
- also supersedes field-before/field-after for the same key. -->
20
+ also supersedes field-before/field-after for the same key. Rendered
21
+ as-is, with no wrapper (#194): `targetAttrs` is the marker DXForm
22
+ looks for to scroll to and focus a field after a failed submit, and
23
+ the consumer opts in by binding it (`v-bind="targetAttrs"`) on the
24
+ element holding the control. DXForm looks the marker up when it
25
+ needs it (a summary click, auto-scroll), never ahead of time. -->
21
26
  <slot
22
27
  v-if="$slots[`field(${field.key})`]"
23
28
  :name="`field(${field.key})`"
24
29
  :field="field"
25
30
  :model="model"
31
+ :targetAttrs="targetAttrs"
26
32
  />
27
33
  <template v-else>
28
34
  <!-- Content inserted directly above the field. -->
@@ -52,7 +58,7 @@
52
58
  </template>
53
59
 
54
60
  <script setup lang="ts">
55
- import { useSlots } from "vue";
61
+ import { computed, useSlots } from "vue";
56
62
  import DXField from "./DXField.vue";
57
63
  import type { UseFormReturn } from "../../composables/useForm";
58
64
  import type { FieldDefinition, LabelCols } from "../../types";
@@ -73,6 +79,9 @@ interface Props {
73
79
  const props = defineProps<Props>();
74
80
  const slots = useSlots();
75
81
 
82
+ /** What a `field(<key>)` slot binds to opt in to scroll/focus (#194). */
83
+ const targetAttrs = computed(() => ({ "data-dx-field-key": props.field.key }));
84
+
76
85
  /**
77
86
  * Map a DXField slot name to this field's keyed parent slot, when present.
78
87
  * A plain function (re-read each render) rather than a computed, so a slot the
@@ -1,12 +1,16 @@
1
1
  <template>
2
+ <!-- The status box is there from the start, holding the loader's space
3
+ with its contents hidden (theme.scss); the delay only reveals them.
4
+ Content added to a live region already in the page is announced,
5
+ where a region inserted with its text can be missed. -->
2
6
  <div
3
- v-if="isVisible"
4
- class="dx-loading text-center text-muted py-5"
7
+ class="dx-loading py-5"
8
+ :class="{ 'dx-loading--shown': isVisible }"
5
9
  role="status"
6
10
  aria-live="polite"
7
11
  >
8
- <DSpinner variant="primary" aria-hidden="true" />
9
- <div class="mt-2">{{ text }}</div>
12
+ <DSpinner small aria-hidden="true" />
13
+ <p class="dx-loading__text">{{ text }}</p>
10
14
  </div>
11
15
  </template>
12
16
 
@@ -14,14 +18,20 @@
14
18
  import { onBeforeUnmount, onMounted, ref } from "vue";
15
19
  import DSpinner from "../base/DSpinner.vue";
16
20
 
21
+ // Styles (one line, grey, a slow sweep across the text, a fade-in, contents
22
+ // hidden until `.dx-loading--shown`) are global in theme.scss under
23
+ // `.dx-loading`, with `--dx-loading-*` tokens.
17
24
  const props = withDefaults(
18
25
  defineProps<{
19
26
  text?: string;
27
+ /** Milliseconds to wait before showing anything. Most loads finish
28
+ * sooner, and one that has already taken this long is likely to take
29
+ * longer, so a quick load never flashes a loader. */
20
30
  delay?: number;
21
31
  }>(),
22
32
  {
23
33
  text: "Loading…",
24
- delay: 300,
34
+ delay: 600,
25
35
  },
26
36
  );
27
37
 
@@ -29,14 +39,11 @@ const isVisible = ref(false);
29
39
  let showTimer: ReturnType<typeof setTimeout> | undefined;
30
40
 
31
41
  onMounted(() => {
32
- if (props.delay <= 0) {
33
- isVisible.value = true;
34
- return;
35
- }
36
-
42
+ // Always a timer, even for no delay: the empty status box must reach the
43
+ // page a task before its contents, or the announcement can be missed.
37
44
  showTimer = setTimeout(() => {
38
45
  isVisible.value = true;
39
- }, props.delay);
46
+ }, Math.max(props.delay, 0));
40
47
  });
41
48
 
42
49
  onBeforeUnmount(() => {
@@ -150,6 +150,7 @@ import type { UseFormReturn } from "../../composables/useForm";
150
150
  import type { FieldDefinition } from "../../types";
151
151
  import { getByPath, setByPath } from "../../utils/objectPath";
152
152
  import { resolveFieldDefault } from "../../utils/formSchema";
153
+ import { humaniseErrorKey } from "../../utils/formErrorTargets";
153
154
 
154
155
  interface Props {
155
156
  /** Form instance owning the repeater array */
@@ -177,12 +178,37 @@ const minItems = computed(() => props.field.minItems ?? 0);
177
178
  const maxItems = computed(() => props.field.maxItems);
178
179
  const softDeleteKey = computed(() => props.field.softDeleteKey);
179
180
 
180
- /** Column header text for a sub-field. No specific row context exists for a
181
- * header, so a function-valued label resolves against the outer model. */
181
+ /**
182
+ * The model a column header's function label is called with. Row fields
183
+ * resolve their labels against the ROW (as does the error summary), so a
184
+ * header uses the first visible row, layered over the outer model so a label
185
+ * that reads outer context (a currency on the parent form) still works. With
186
+ * no rows it is the outer model alone.
187
+ */
188
+ const headerModel = computed<Record<string, any>>(() => {
189
+ const firstRow = visibleRows.value[0]?.row;
190
+ return {
191
+ ...(props.model ?? {}),
192
+ ...(firstRow !== null && typeof firstRow === "object" ? firstRow : {}),
193
+ };
194
+ });
195
+
196
+ /** Column header text for a sub-field. A label function that throws (a row
197
+ * property it expects is missing) or returns nothing falls back to the
198
+ * humanised key, the same fallback the error summary uses, so a header can
199
+ * never break the table. */
182
200
  function headerLabel(subField: FieldDefinition): string {
183
201
  const label = subField.label;
184
- if (typeof label === "function") return label(props.model ?? {});
185
- return label || subField.key;
202
+ if (typeof label !== "function") return label || subField.key;
203
+ let resolved: unknown;
204
+ try {
205
+ resolved = label(headerModel.value);
206
+ } catch {
207
+ resolved = undefined;
208
+ }
209
+ return typeof resolved === "string" && resolved.trim() !== ""
210
+ ? resolved
211
+ : humaniseErrorKey(subField.key);
186
212
  }
187
213
 
188
214
  // ————————————————— table/cards responsive fallback (#68 follow-up)
@@ -35,10 +35,7 @@
35
35
  {{ error || apiError }}
36
36
  </div>
37
37
 
38
- <div v-if="effectiveBusy && !isProviderMode" class="text-center py-5">
39
- <DSpinner variant="primary" />
40
- <p class="mt-2">{{ loadingText }}</p>
41
- </div>
38
+ <DXLoading v-if="effectiveBusy && !isProviderMode" :text="loadingText" />
42
39
 
43
40
  <!-- One table for all three data modes. The modes differ only
44
41
  in a few bound props, supplied by `tableModeBindings`, so
@@ -140,7 +137,7 @@
140
137
  <DFormSelect
141
138
  v-else-if="field.filter === 'select-native'"
142
139
  :model-value="nativeSelectFilterValue(field)"
143
- :options="getFieldFilterOptions(field) as unknown as Record<string, unknown>[]"
140
+ :options="getFieldFilterOptions(field)"
144
141
  size="sm"
145
142
  @update:model-value="handleSelectFilterChange(field, $event)"
146
143
  />
@@ -292,6 +289,8 @@
292
289
  :save-text="saveText"
293
290
  :create-text="createText"
294
291
  :delete-text="deleteText"
292
+ :error-summary-title="errorSummaryTitle"
293
+ :error-summary-other-title="errorSummaryOtherTitle"
295
294
  @save="handleEditSave"
296
295
  @cancel="handleEditCancel"
297
296
  @delete="handleDelete"
@@ -324,7 +323,7 @@ import DContainer from "../base/DContainer.vue";
324
323
  import DRow from "../base/DRow.vue";
325
324
  import DCol from "../base/DCol.vue";
326
325
  import DXTableShell from "./DXTableShell.vue";
327
- import DSpinner from "../base/DSpinner.vue";
326
+ import DXLoading from "./DXLoading.vue";
328
327
  import DTable from "../base/DTable.vue";
329
328
  import DFormInput from "../base/DFormInput.vue";
330
329
  import DFormSelect from "../base/DFormSelect.vue";
@@ -709,6 +708,19 @@ export interface Props<TItem = any> {
709
708
  /** Override the modal's Delete button label (default: "Delete {item}"). */
710
709
  deleteText?: string;
711
710
 
711
+ /**
712
+ * Heading of the modal's failed-save summary when the failure has field
713
+ * errors (default "Couldn't save. Please check:"). A failure with none
714
+ * is headed by the server's message instead.
715
+ */
716
+ errorSummaryTitle?: string;
717
+
718
+ /**
719
+ * Sub-heading over the summary rows no edit field owns (default "Other
720
+ * problems"), shown only when some rows ARE owned.
721
+ */
722
+ errorSummaryOtherTitle?: string;
723
+
712
724
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
713
725
  editUrl?: string;
714
726
 
@@ -826,6 +838,16 @@ export interface Props<TItem = any> {
826
838
  */
827
839
  rowClickable?: (item: TItem, index: number) => boolean;
828
840
 
841
+ /**
842
+ * Whether clicking a row opens the edit modal (when `editFields` are set).
843
+ * Defaults to `true`. Set `false` when `editFields` exist only for the
844
+ * create modal (`openCreate()` / the create button) and a row click does
845
+ * something else, such as opening the record's own page from `row-clicked`:
846
+ * the row then emits `row-clicked` without also opening the edit modal, and
847
+ * only a `row-clicked` listener makes rows look clickable (#200).
848
+ */
849
+ editOnRowClick?: boolean;
850
+
829
851
  /** Enable client-side filtering, sorting, and pagination on items array */
830
852
  clientSide?: boolean;
831
853
 
@@ -886,6 +908,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
886
908
  footClone: false,
887
909
  showEmpty: true,
888
910
  fixedLayout: false,
911
+ editOnRowClick: true,
889
912
  });
890
913
 
891
914
  const emit = defineEmits<{
@@ -1038,9 +1061,12 @@ const isControlled = {
1038
1061
  * The cursor and hover affordance follow from that — DXTable already knows,
1039
1062
  * so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
1040
1063
  * The listener case was the gap: only `editFields` used to count.
1064
+ * `editFields` count only while a row click opens them (`editOnRowClick`, #200).
1041
1065
  */
1042
1066
  const rowsAreInteractive = computed(
1043
- () => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
1067
+ () =>
1068
+ (props.editOnRowClick && (props.editFields?.length ?? 0) > 0) ||
1069
+ hasListener('rowClicked'),
1044
1070
  );
1045
1071
 
1046
1072
  /**
@@ -2451,7 +2477,8 @@ const getFieldSortState = (fieldKey: string) => {
2451
2477
  };
2452
2478
 
2453
2479
  // Handle row click. Emits `rowClicked` regardless (the seam consumers listen
2454
- // on), then asks the editor to open — a no-op unless `editFields` are set.
2480
+ // on), then asks the editor to open — a no-op unless `editFields` are set, and
2481
+ // skipped when the consumer turned edit-on-click off (#200).
2455
2482
  const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2456
2483
  // A row the consumer marked non-actionable doesn't look clickable, so it
2457
2484
  // must not BE clickable either — otherwise a click that looks dead quietly
@@ -2459,7 +2486,9 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2459
2486
  if (props.rowClickable && !props.rowClickable(item, index)) return;
2460
2487
 
2461
2488
  emit('rowClicked', item, index, event);
2462
- editor.openEdit(item);
2489
+ if (props.editOnRowClick) {
2490
+ editor.openEdit(item);
2491
+ }
2463
2492
  };
2464
2493
 
2465
2494
  defineExpose({
@@ -55,6 +55,7 @@
55
55
  :aria-busy="loading ? 'true' : undefined"
56
56
  >
57
57
  <DXForm
58
+ ref="innerForm"
58
59
  :key="formInstanceKey"
59
60
  :active-tab="activeTab"
60
61
  :form="form"
@@ -66,6 +67,7 @@
66
67
  :card-tabs="false"
67
68
  :context="item ?? undefined"
68
69
  :show-submit="false"
70
+ error-summary="external"
69
71
  @update:active-tab="emit('update:activeTab', $event)"
70
72
  @submit="emit('save')"
71
73
  >
@@ -154,6 +156,24 @@
154
156
  </fieldset>
155
157
 
156
158
  <template #footer>
159
+ <!-- What the last failed save returned, beside the Save button it
160
+ answers (#194). The inner DXForm shows no summary or alert of
161
+ its own while this one lists a failure
162
+ (`error-summary="external"`; `false` would bring its top
163
+ alert back), so a failure shows one alert. Rows come from the inner form's own resolution, and a
164
+ row click drives the inner form's tab and focus. -->
165
+ <DXFormErrorSummary
166
+ v-if="form"
167
+ class="dx-edit-error-summary w-100"
168
+ :form="form"
169
+ :fields="fields"
170
+ :tabs="tabs"
171
+ :context="item ?? undefined"
172
+ :targets="innerForm?.errorTargets ?? null"
173
+ :title="errorSummaryTitle"
174
+ :other-title="errorSummaryOtherTitle"
175
+ @select-target="innerForm?.focusErrorTarget($event)"
176
+ />
157
177
  <div class="d-flex justify-content-between w-100">
158
178
  <div>
159
179
  <DButton
@@ -187,11 +207,12 @@
187
207
  </template>
188
208
 
189
209
  <script setup lang="ts">
190
- import { computed, useSlots } from "vue";
210
+ import { computed, ref, useSlots } from "vue";
191
211
  import DModal from "../base/DModal.vue";
192
212
  import DButton from "../base/DButton.vue";
193
213
  import DSpinner from "../base/DSpinner.vue";
194
214
  import DXForm from "./DXForm.vue";
215
+ import DXFormErrorSummary from "./DXFormErrorSummary.vue";
195
216
  import type { EditTab } from "./DXTable.vue";
196
217
  import type { LabelCols } from "../../types";
197
218
 
@@ -241,10 +262,17 @@ interface Props {
241
262
  createText?: string;
242
263
  /** Override the Delete button label. */
243
264
  deleteText?: string;
265
+ /** The footer summary's heading for a failure with field errors. */
266
+ errorSummaryTitle?: string;
267
+ /** The footer summary's sub-heading over rows no field owns. */
268
+ errorSummaryOtherTitle?: string;
244
269
  }
245
270
 
246
271
  const props = defineProps<Props>();
247
272
 
273
+ /** The inner form: its resolved error rows, and focusing an errored field. */
274
+ const innerForm = ref<InstanceType<typeof DXForm> | null>(null);
275
+
248
276
  // Title-case the singular item noun for button labels ("customer" → "Customer",
249
277
  // "sales order" → "Sales Order") — capitalize the first letter of each word.
250
278
  const capitalizedItem = computed(() => {
@@ -8,13 +8,26 @@ import type { FieldDefinition, FieldOption } from "../types";
8
8
  * out-of-order responses clobbering newer ones; loader failures are swallowed
9
9
  * so the last good options (or the static `field.options` fallback) stay put.
10
10
  *
11
- * Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
11
+ * Known limit: replacing a field's `optionsLoader` at runtime does NOT restart
12
+ * the "first load" state (loaded options and `firstLoadSettled` carry over).
13
+ * Loader identity is not a stable signal: an inline loader in a computed
14
+ * `fields` array is a new function on every recompute, so treating each new
15
+ * function as a new source would blank the field and refetch on every
16
+ * unrelated edit.
17
+ *
18
+ * Extracted from DXField's inline logic (#135).
12
19
  * `getField` is an accessor (props aren't destructurable without losing
13
20
  * reactivity); `effectiveModel` is the model passed to the loader.
14
21
  */
15
22
  export interface UseAsyncOptionsReturn {
16
23
  /** Options returned by the most recent successful load, or null if none yet. */
17
24
  loadedOptions: Ref<FieldOption[] | null>;
25
+ /**
26
+ * False until the first load has SETTLED (succeeded or failed). Only that
27
+ * first load leaves a field with nothing to show; a later reload keeps the
28
+ * options it has, or the static fallback after a failure.
29
+ */
30
+ firstLoadSettled: Ref<boolean>;
18
31
  /** Trigger a load (no-op when the field has no `optionsLoader`). */
19
32
  loadOptions: () => Promise<void>;
20
33
  }
@@ -24,6 +37,9 @@ export function useAsyncOptions(
24
37
  effectiveModel: ComputedRef<any> | Ref<any>,
25
38
  ): UseAsyncOptionsReturn {
26
39
  const loadedOptions = ref<FieldOption[] | null>(null);
40
+ // False before mount when there is a loader, so the first render already
41
+ // knows options are on their way.
42
+ const firstLoadSettled = ref(!getField().optionsLoader);
27
43
 
28
44
  // Monotonic token so out-of-order async responses can't clobber newer ones.
29
45
  let optionsRequestToken = 0;
@@ -46,6 +62,8 @@ export function useAsyncOptions(
46
62
  error,
47
63
  );
48
64
  }
65
+ } finally {
66
+ if (token === optionsRequestToken) firstLoadSettled.value = true;
49
67
  }
50
68
  }
51
69
 
@@ -53,9 +71,16 @@ export function useAsyncOptions(
53
71
  void loadOptions();
54
72
  });
55
73
 
56
- if (getField().reloadOptionsOnChange) {
57
- watch(effectiveModel, () => void loadOptions(), { deep: true });
58
- }
74
+ // Read from the CURRENT field: DXForm keys DXField by `field.key`, so
75
+ // recomputed `fields` hand this instance a new field object that may
76
+ // turn reloading on or off.
77
+ watch(
78
+ effectiveModel,
79
+ () => {
80
+ if (getField().reloadOptionsOnChange) void loadOptions();
81
+ },
82
+ { deep: true },
83
+ );
59
84
 
60
- return { loadedOptions, loadOptions };
85
+ return { loadedOptions, firstLoadSettled, loadOptions };
61
86
  }