@omnitend/dashboard-for-laravel 0.43.0 → 0.44.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 (38) 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/DXTable.vue.d.ts +20 -0
  7. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  8. package/dist/composables/useAsyncOptions.d.ts +14 -1
  9. package/dist/composables/useForm.d.ts +26 -0
  10. package/dist/dashboard-for-laravel.js +13374 -12823
  11. package/dist/dashboard-for-laravel.js.map +1 -1
  12. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  13. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/style.css +1 -1
  16. package/dist/types/index.d.ts +12 -0
  17. package/dist/utils/formErrorTargets.d.ts +86 -0
  18. package/docs/public/api-reference.json +146 -6
  19. package/docs/public/docs-map.md +3 -2
  20. package/docs/public/llms.txt +3 -2
  21. package/package.json +9 -8
  22. package/resources/css/theme.scss +56 -0
  23. package/resources/js/components/base/DFormSelect.vue +5 -2
  24. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  25. package/resources/js/components/extended/DXField.vue +15 -9
  26. package/resources/js/components/extended/DXForm.vue +676 -28
  27. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  28. package/resources/js/components/extended/DXFormField.vue +11 -2
  29. package/resources/js/components/extended/DXRepeater.vue +30 -4
  30. package/resources/js/components/extended/DXTable.vue +36 -4
  31. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  32. package/resources/js/composables/useAsyncOptions.ts +30 -5
  33. package/resources/js/composables/useForm.ts +196 -18
  34. package/resources/js/composables/useResourceEditor.ts +140 -107
  35. package/resources/js/index.ts +15 -0
  36. package/resources/js/types/index.ts +13 -0
  37. package/resources/js/utils/formErrorTargets.ts +317 -0
  38. 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
@@ -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)
@@ -140,7 +140,7 @@
140
140
  <DFormSelect
141
141
  v-else-if="field.filter === 'select-native'"
142
142
  :model-value="nativeSelectFilterValue(field)"
143
- :options="getFieldFilterOptions(field) as unknown as Record<string, unknown>[]"
143
+ :options="getFieldFilterOptions(field)"
144
144
  size="sm"
145
145
  @update:model-value="handleSelectFilterChange(field, $event)"
146
146
  />
@@ -292,6 +292,8 @@
292
292
  :save-text="saveText"
293
293
  :create-text="createText"
294
294
  :delete-text="deleteText"
295
+ :error-summary-title="errorSummaryTitle"
296
+ :error-summary-other-title="errorSummaryOtherTitle"
295
297
  @save="handleEditSave"
296
298
  @cancel="handleEditCancel"
297
299
  @delete="handleDelete"
@@ -709,6 +711,19 @@ export interface Props<TItem = any> {
709
711
  /** Override the modal's Delete button label (default: "Delete {item}"). */
710
712
  deleteText?: string;
711
713
 
714
+ /**
715
+ * Heading of the modal's failed-save summary when the failure has field
716
+ * errors (default "Couldn't save. Please check:"). A failure with none
717
+ * is headed by the server's message instead.
718
+ */
719
+ errorSummaryTitle?: string;
720
+
721
+ /**
722
+ * Sub-heading over the summary rows no edit field owns (default "Other
723
+ * problems"), shown only when some rows ARE owned.
724
+ */
725
+ errorSummaryOtherTitle?: string;
726
+
712
727
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
713
728
  editUrl?: string;
714
729
 
@@ -826,6 +841,16 @@ export interface Props<TItem = any> {
826
841
  */
827
842
  rowClickable?: (item: TItem, index: number) => boolean;
828
843
 
844
+ /**
845
+ * Whether clicking a row opens the edit modal (when `editFields` are set).
846
+ * Defaults to `true`. Set `false` when `editFields` exist only for the
847
+ * create modal (`openCreate()` / the create button) and a row click does
848
+ * something else, such as opening the record's own page from `row-clicked`:
849
+ * the row then emits `row-clicked` without also opening the edit modal, and
850
+ * only a `row-clicked` listener makes rows look clickable (#200).
851
+ */
852
+ editOnRowClick?: boolean;
853
+
829
854
  /** Enable client-side filtering, sorting, and pagination on items array */
830
855
  clientSide?: boolean;
831
856
 
@@ -886,6 +911,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
886
911
  footClone: false,
887
912
  showEmpty: true,
888
913
  fixedLayout: false,
914
+ editOnRowClick: true,
889
915
  });
890
916
 
891
917
  const emit = defineEmits<{
@@ -1038,9 +1064,12 @@ const isControlled = {
1038
1064
  * The cursor and hover affordance follow from that — DXTable already knows,
1039
1065
  * so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
1040
1066
  * The listener case was the gap: only `editFields` used to count.
1067
+ * `editFields` count only while a row click opens them (`editOnRowClick`, #200).
1041
1068
  */
1042
1069
  const rowsAreInteractive = computed(
1043
- () => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
1070
+ () =>
1071
+ (props.editOnRowClick && (props.editFields?.length ?? 0) > 0) ||
1072
+ hasListener('rowClicked'),
1044
1073
  );
1045
1074
 
1046
1075
  /**
@@ -2451,7 +2480,8 @@ const getFieldSortState = (fieldKey: string) => {
2451
2480
  };
2452
2481
 
2453
2482
  // 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.
2483
+ // on), then asks the editor to open — a no-op unless `editFields` are set, and
2484
+ // skipped when the consumer turned edit-on-click off (#200).
2455
2485
  const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2456
2486
  // A row the consumer marked non-actionable doesn't look clickable, so it
2457
2487
  // must not BE clickable either — otherwise a click that looks dead quietly
@@ -2459,7 +2489,9 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2459
2489
  if (props.rowClickable && !props.rowClickable(item, index)) return;
2460
2490
 
2461
2491
  emit('rowClicked', item, index, event);
2462
- editor.openEdit(item);
2492
+ if (props.editOnRowClick) {
2493
+ editor.openEdit(item);
2494
+ }
2463
2495
  };
2464
2496
 
2465
2497
  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
  }