@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.
- package/dist/components/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- package/dist/components/extended/DXForm.vue.d.ts +66 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
- package/dist/components/extended/DXFormField.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
- package/dist/composables/useAsyncOptions.d.ts +14 -1
- package/dist/composables/useForm.d.ts +26 -0
- package/dist/dashboard-for-laravel.js +13374 -12823
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/docs/public/api-reference.json +146 -6
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +56 -0
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXField.vue +15 -9
- package/resources/js/components/extended/DXForm.vue +676 -28
- package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
- package/resources/js/components/extended/DXFormField.vue +11 -2
- package/resources/js/components/extended/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXTable.vue +36 -4
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +15 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- 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
|
-
/**
|
|
181
|
-
*
|
|
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
|
|
185
|
-
|
|
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)
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
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
|
}
|