@omnitend/dashboard-for-laravel 0.4.14 → 0.6.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/DButton.vue.d.ts +2 -3
- package/dist/components/base/DTable.vue.d.ts +11 -3
- package/dist/components/extended/DXBasicForm.vue.d.ts +4 -33
- package/dist/components/extended/DXField.vue.d.ts +88 -0
- package/dist/components/extended/DXForm.vue.d.ts +34 -8
- package/dist/components/extended/DXRepeater.vue.d.ts +30 -0
- package/dist/components/extended/DXTable.vue.d.ts +10 -17
- package/dist/dashboard-for-laravel.js +31131 -16052
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +10 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +114 -9
- package/dist/utils/objectPath.d.ts +18 -0
- package/docs/public/api-reference.json +354 -130
- package/docs/public/docs-map.md +5 -4
- package/docs/public/llms.txt +8 -7
- package/package.json +3 -3
- package/resources/js/components/base/DButton.vue +2 -3
- package/resources/js/components/base/{DCarousel.vue → DFormRadioGroup.vue} +5 -5
- package/resources/js/components/base/DTable.vue +39 -3
- package/resources/js/components/base/DToaster.vue +5 -3
- package/resources/js/components/extended/DXBasicForm.vue +35 -184
- package/resources/js/components/extended/DXField.vue +402 -0
- package/resources/js/components/extended/DXForm.vue +282 -17
- package/resources/js/components/extended/DXRepeater.vue +216 -0
- package/resources/js/components/extended/DXTable.vue +96 -210
- package/resources/js/composables/defineForm.ts +7 -0
- package/resources/js/index.ts +18 -3
- package/resources/js/types/index.ts +146 -9
- package/resources/js/utils/objectPath.ts +59 -0
- package/dist/components/base/DCarouselSlide.vue.d.ts +0 -12
- package/resources/js/components/base/DCarouselSlide.vue +0 -14
- /package/dist/components/base/{DCarousel.vue.d.ts → DFormRadioGroup.vue.d.ts} +0 -0
|
@@ -1,26 +1,137 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
DXForm — the canonical form renderer.
|
|
3
|
+
|
|
4
|
+
Driven by field definitions, with optional tabs. Renders every field
|
|
5
|
+
through DXField (the single field engine), so flat and tabbed forms share
|
|
6
|
+
one code path. Supports conditional fields/tabs, per-field slot overrides,
|
|
7
|
+
async options, nested repeaters, and auto-switching to the first tab
|
|
8
|
+
containing a validation error.
|
|
9
|
+
|
|
10
|
+
Accepts either a `useForm` return or a `defineForm` return; with the latter
|
|
11
|
+
`fields` may be omitted. Works with the fetch-based `useForm` composable
|
|
12
|
+
(no Inertia required).
|
|
13
|
+
-->
|
|
1
14
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
<BForm @submit.prevent="handleSubmit">
|
|
16
|
+
<!-- Form-level error message -->
|
|
17
|
+
<DAlert
|
|
18
|
+
v-if="resolvedForm.shouldShowMessage"
|
|
19
|
+
:model-value="resolvedForm.shouldShowMessage"
|
|
20
|
+
variant="danger"
|
|
21
|
+
class="mb-3"
|
|
22
|
+
>
|
|
23
|
+
{{ resolvedForm.message }}
|
|
24
|
+
</DAlert>
|
|
25
|
+
|
|
26
|
+
<!-- Tabbed layout. BTabs exposes the active *index* via v-model:index
|
|
27
|
+
(plain v-model is the active tab id), which is what we track. -->
|
|
28
|
+
<DTabs v-if="hasTabs" v-model:index="activeTab">
|
|
29
|
+
<DTab
|
|
30
|
+
v-for="(tab, index) in visibleTabs"
|
|
31
|
+
:key="tab.key"
|
|
32
|
+
:title="tab.label || tab.key"
|
|
33
|
+
:lazy="tab.lazy"
|
|
34
|
+
:active="index === 0"
|
|
35
|
+
>
|
|
36
|
+
<!-- Replace the entire tab body -->
|
|
37
|
+
<slot
|
|
38
|
+
v-if="$slots[`tab-content(${tab.key})`]"
|
|
39
|
+
:name="`tab-content(${tab.key})`"
|
|
40
|
+
:tab="tab"
|
|
41
|
+
:model="model"
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<div v-else class="pt-3">
|
|
45
|
+
<slot :name="`tab-before(${tab.key})`" :tab="tab" :model="model" />
|
|
46
|
+
|
|
47
|
+
<DXField
|
|
48
|
+
v-for="field in visibleFieldsFor(tab)"
|
|
49
|
+
:key="field.key"
|
|
50
|
+
:field="field"
|
|
51
|
+
:form="resolvedForm"
|
|
52
|
+
:model="model"
|
|
53
|
+
>
|
|
54
|
+
<template
|
|
55
|
+
v-for="(slotName, target) in fieldSlotMap(field.key)"
|
|
56
|
+
:key="target"
|
|
57
|
+
#[target]="slotProps"
|
|
58
|
+
>
|
|
59
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
60
|
+
</template>
|
|
61
|
+
</DXField>
|
|
62
|
+
|
|
63
|
+
<slot :name="`tab-after(${tab.key})`" :tab="tab" :model="model" />
|
|
64
|
+
</div>
|
|
65
|
+
</DTab>
|
|
66
|
+
</DTabs>
|
|
67
|
+
|
|
68
|
+
<!-- Flat layout (no tabs) -->
|
|
69
|
+
<template v-else>
|
|
70
|
+
<DXField
|
|
71
|
+
v-for="field in visibleFlatFields"
|
|
72
|
+
:key="field.key"
|
|
73
|
+
:field="field"
|
|
74
|
+
:form="resolvedForm"
|
|
75
|
+
:model="model"
|
|
76
|
+
>
|
|
77
|
+
<template
|
|
78
|
+
v-for="(slotName, target) in fieldSlotMap(field.key)"
|
|
79
|
+
:key="target"
|
|
80
|
+
#[target]="slotProps"
|
|
81
|
+
>
|
|
82
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
83
|
+
</template>
|
|
84
|
+
</DXField>
|
|
13
85
|
</template>
|
|
14
|
-
|
|
86
|
+
|
|
87
|
+
<!-- Submit button -->
|
|
88
|
+
<DButton
|
|
89
|
+
v-if="showSubmit"
|
|
90
|
+
type="submit"
|
|
91
|
+
variant="primary"
|
|
92
|
+
:disabled="resolvedForm.processing"
|
|
93
|
+
class="w-100 mt-3"
|
|
94
|
+
>
|
|
95
|
+
<span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
|
|
96
|
+
<span v-else>{{ submitText }}</span>
|
|
97
|
+
</DButton>
|
|
98
|
+
|
|
99
|
+
<slot name="footer" :form="resolvedForm" />
|
|
100
|
+
</BForm>
|
|
15
101
|
</template>
|
|
16
102
|
|
|
17
103
|
<script setup lang="ts">
|
|
18
|
-
import
|
|
104
|
+
import { computed, watch } from "vue";
|
|
105
|
+
import { BForm } from "bootstrap-vue-next";
|
|
106
|
+
import DAlert from "../base/DAlert.vue";
|
|
107
|
+
import DButton from "../base/DButton.vue";
|
|
108
|
+
import DTabs from "../base/DTabs.vue";
|
|
109
|
+
import DTab from "../base/DTab.vue";
|
|
110
|
+
import DXField from "./DXField.vue";
|
|
111
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
19
112
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
113
|
+
import type { FieldDefinition, FormTab, MaybeFn } from "../../types";
|
|
20
114
|
|
|
21
115
|
interface Props {
|
|
22
|
-
/**
|
|
23
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Form instance — either a raw `useForm` return or a `defineForm`
|
|
118
|
+
* return (`{ form, fields }`). With the latter, `fields` may be
|
|
119
|
+
* omitted and is taken from the form object.
|
|
120
|
+
*/
|
|
121
|
+
form: UseFormReturn<any> | DefineFormReturn<any>;
|
|
122
|
+
|
|
123
|
+
/** Field definitions (optional when `form` is a defineForm return). */
|
|
124
|
+
fields?: FieldDefinition[];
|
|
125
|
+
|
|
126
|
+
/** Tab definitions. When omitted, a flat single-column form renders. */
|
|
127
|
+
tabs?: FormTab[];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Extra context merged under the live form data when evaluating
|
|
131
|
+
* predicates (label/hint/when/disabled). E.g. a table passes the
|
|
132
|
+
* original row so predicates can read non-edited columns.
|
|
133
|
+
*/
|
|
134
|
+
context?: Record<string, any>;
|
|
24
135
|
|
|
25
136
|
/** Submit button text */
|
|
26
137
|
submitText?: string;
|
|
@@ -28,17 +139,171 @@ interface Props {
|
|
|
28
139
|
/** Submit button loading text */
|
|
29
140
|
submitLoadingText?: string;
|
|
30
141
|
|
|
31
|
-
/** Show submit button */
|
|
142
|
+
/** Show the submit button */
|
|
32
143
|
showSubmit?: boolean;
|
|
144
|
+
|
|
145
|
+
/** Auto-switch to the first tab containing a validation error. */
|
|
146
|
+
autoErrorTab?: boolean;
|
|
33
147
|
}
|
|
34
148
|
|
|
35
|
-
withDefaults(defineProps<Props>(), {
|
|
149
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
36
150
|
submitText: "Submit",
|
|
37
151
|
submitLoadingText: "Submitting...",
|
|
38
152
|
showSubmit: true,
|
|
153
|
+
autoErrorTab: true,
|
|
39
154
|
});
|
|
40
155
|
|
|
41
156
|
const emit = defineEmits<{
|
|
42
157
|
submit: [];
|
|
43
158
|
}>();
|
|
159
|
+
|
|
160
|
+
const slots = defineSlots<Record<string, (props: any) => any>>();
|
|
161
|
+
|
|
162
|
+
/** v-model for the active tab index. */
|
|
163
|
+
const activeTab = defineModel<number>("activeTab", { default: 0 });
|
|
164
|
+
|
|
165
|
+
// ————————————————— resolve form / fields (accept useForm or defineForm)
|
|
166
|
+
|
|
167
|
+
function isDefineForm(
|
|
168
|
+
value: Props["form"],
|
|
169
|
+
): value is DefineFormReturn<any> {
|
|
170
|
+
return (
|
|
171
|
+
!!value &&
|
|
172
|
+
typeof value === "object" &&
|
|
173
|
+
"form" in value &&
|
|
174
|
+
"fields" in value &&
|
|
175
|
+
!("data" in value)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const resolvedForm = computed<UseFormReturn<any>>(() =>
|
|
180
|
+
isDefineForm(props.form) ? props.form.form : props.form,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const resolvedFields = computed<FieldDefinition[]>(() => {
|
|
184
|
+
if (props.fields) return props.fields;
|
|
185
|
+
if (isDefineForm(props.form)) return props.form.fields;
|
|
186
|
+
return [];
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const fieldByKey = computed<Record<string, FieldDefinition>>(() => {
|
|
190
|
+
const map: Record<string, FieldDefinition> = {};
|
|
191
|
+
for (const field of resolvedFields.value) map[field.key] = field;
|
|
192
|
+
return map;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ————————————————— model for predicates (live form data + context)
|
|
196
|
+
|
|
197
|
+
const model = computed(() => ({
|
|
198
|
+
...(props.context ?? {}),
|
|
199
|
+
...resolvedForm.value.data,
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
function resolvePredicate(
|
|
203
|
+
when: MaybeFn<boolean> | undefined,
|
|
204
|
+
fallback: boolean,
|
|
205
|
+
): boolean {
|
|
206
|
+
if (when === undefined) return fallback;
|
|
207
|
+
return typeof when === "function" ? when(model.value) : when;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isFieldVisible(field: FieldDefinition): boolean {
|
|
211
|
+
const whenOk = resolvePredicate(field.when, true);
|
|
212
|
+
const showOk = field.show ? field.show() : true;
|
|
213
|
+
return whenOk && showOk;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ————————————————— tabs
|
|
217
|
+
|
|
218
|
+
const hasTabs = computed(
|
|
219
|
+
() => !!props.tabs && props.tabs.length > 0,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
function visibleFieldsFor(tab: FormTab): FieldDefinition[] {
|
|
223
|
+
return tab.fieldKeys
|
|
224
|
+
.map((key) => fieldByKey.value[key])
|
|
225
|
+
.filter((field): field is FieldDefinition => !!field)
|
|
226
|
+
.filter(isFieldVisible);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A tab with a custom body/before/after slot has content even with no fields. */
|
|
230
|
+
function hasTabSlot(key: string): boolean {
|
|
231
|
+
return !!(
|
|
232
|
+
slots[`tab-content(${key})`] ||
|
|
233
|
+
slots[`tab-before(${key})`] ||
|
|
234
|
+
slots[`tab-after(${key})`]
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const visibleTabs = computed<FormTab[]>(() => {
|
|
239
|
+
if (!props.tabs) return [];
|
|
240
|
+
return props.tabs.filter((tab) => {
|
|
241
|
+
if (!resolvePredicate(tab.when, true)) return false;
|
|
242
|
+
// Hide tabs with no visible fields, unless the consumer supplies a
|
|
243
|
+
// custom tab-content/before/after slot for that tab.
|
|
244
|
+
return visibleFieldsFor(tab).length > 0 || hasTabSlot(tab.key);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const visibleFlatFields = computed<FieldDefinition[]>(() =>
|
|
249
|
+
resolvedFields.value.filter(isFieldVisible),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// ————————————————— per-field slot forwarding
|
|
253
|
+
|
|
254
|
+
/** Map a DXField slot name to the parent's keyed slot, when present. */
|
|
255
|
+
function fieldSlotMap(key: string): Record<string, string> {
|
|
256
|
+
const map: Record<string, string> = {};
|
|
257
|
+
const candidates: Array<[string, string]> = [
|
|
258
|
+
["value", `value(${key})`],
|
|
259
|
+
["span", `span(${key})`],
|
|
260
|
+
["info", `info(${key})`],
|
|
261
|
+
["hint", `hint(${key})`],
|
|
262
|
+
["repeater-row", `repeater-row(${key})`],
|
|
263
|
+
];
|
|
264
|
+
for (const [target, source] of candidates) {
|
|
265
|
+
if (slots[source]) map[target] = source;
|
|
266
|
+
}
|
|
267
|
+
return map;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ————————————————— auto-switch to the first error tab
|
|
271
|
+
|
|
272
|
+
/** Keys that currently carry at least one validation error. */
|
|
273
|
+
const erroredKeys = computed<string[]>(() =>
|
|
274
|
+
Object.keys(resolvedForm.value.errors).filter(
|
|
275
|
+
(key) => (resolvedForm.value.errors[key]?.length ?? 0) > 0,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
function goToErrorTab(): void {
|
|
280
|
+
if (!hasTabs.value || erroredKeys.value.length === 0) return;
|
|
281
|
+
const tabIndex = visibleTabs.value.findIndex((tab) =>
|
|
282
|
+
tab.fieldKeys.some((key) =>
|
|
283
|
+
erroredKeys.value.some(
|
|
284
|
+
// Match exact keys and nested (repeater) error keys.
|
|
285
|
+
(errorKey) => errorKey === key || errorKey.startsWith(`${key}.`),
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
if (tabIndex !== -1) activeTab.value = tabIndex;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Watch a primitive derived from the error keys so the effect reliably
|
|
293
|
+
// re-runs when errors are set (deep-watching the stable reactive object
|
|
294
|
+
// reference does not fire on key additions). `immediate` handles errors
|
|
295
|
+
// already present on the form before mount.
|
|
296
|
+
watch(
|
|
297
|
+
() => erroredKeys.value.join("|"),
|
|
298
|
+
(joined) => {
|
|
299
|
+
if (props.autoErrorTab && joined) goToErrorTab();
|
|
300
|
+
},
|
|
301
|
+
{ immediate: true },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
function handleSubmit(): void {
|
|
305
|
+
emit("submit");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
defineExpose({ goToErrorTab });
|
|
44
309
|
</script>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="dx-repeater">
|
|
3
|
+
<div
|
|
4
|
+
v-for="(row, index) in rows"
|
|
5
|
+
:key="rowKey(index)"
|
|
6
|
+
class="dx-repeater-row"
|
|
7
|
+
>
|
|
8
|
+
<!-- Custom row layout escape hatch -->
|
|
9
|
+
<slot
|
|
10
|
+
v-if="$slots.row"
|
|
11
|
+
name="row"
|
|
12
|
+
:row="row"
|
|
13
|
+
:index="index"
|
|
14
|
+
:fields="subFields"
|
|
15
|
+
:remove="() => removeRow(index)"
|
|
16
|
+
:path="rowPath(index)"
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
<!-- Default row: stack each sub-field -->
|
|
20
|
+
<template v-else>
|
|
21
|
+
<div class="dx-repeater-row-header">
|
|
22
|
+
<span class="dx-repeater-row-index">{{ index + 1 }}</span>
|
|
23
|
+
<DButton
|
|
24
|
+
variant="outline-danger"
|
|
25
|
+
size="sm"
|
|
26
|
+
:disabled="rows.length <= minItems"
|
|
27
|
+
@click="removeRow(index)"
|
|
28
|
+
>
|
|
29
|
+
Remove
|
|
30
|
+
</DButton>
|
|
31
|
+
</div>
|
|
32
|
+
<DXField
|
|
33
|
+
v-for="subField in subFields"
|
|
34
|
+
:key="subField.key"
|
|
35
|
+
:field="subField"
|
|
36
|
+
:form="form"
|
|
37
|
+
:model="row"
|
|
38
|
+
:key-path="`${rowPath(index)}.${subField.key}`"
|
|
39
|
+
:error-key="`${rowPath(index)}.${subField.key}`"
|
|
40
|
+
/>
|
|
41
|
+
</template>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<DButton
|
|
45
|
+
variant="outline-primary"
|
|
46
|
+
size="sm"
|
|
47
|
+
:disabled="maxItems !== undefined && rows.length >= maxItems"
|
|
48
|
+
@click="addRow"
|
|
49
|
+
>
|
|
50
|
+
{{ field.addLabel || "Add" }}
|
|
51
|
+
</DButton>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script setup lang="ts">
|
|
56
|
+
import { computed } from "vue";
|
|
57
|
+
import DButton from "../base/DButton.vue";
|
|
58
|
+
import DXField from "./DXField.vue";
|
|
59
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
60
|
+
import type { FieldDefinition, FieldType } from "../../types";
|
|
61
|
+
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
62
|
+
|
|
63
|
+
interface Props {
|
|
64
|
+
/** Form instance owning the repeater array */
|
|
65
|
+
form: UseFormReturn<any>;
|
|
66
|
+
|
|
67
|
+
/** The repeater field definition (provides sub-fields, limits, labels) */
|
|
68
|
+
field: FieldDefinition;
|
|
69
|
+
|
|
70
|
+
/** Dot path into form.data for the array (defaults to field.key) */
|
|
71
|
+
keyPath?: string;
|
|
72
|
+
|
|
73
|
+
/** Model passed to predicates from the parent context */
|
|
74
|
+
model?: any;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const props = defineProps<Props>();
|
|
78
|
+
|
|
79
|
+
// The array key is a dot PATH: rows nest into it (`lines.0.price`), and that
|
|
80
|
+
// path is also the validation-error key Laravel returns. So a repeater (or
|
|
81
|
+
// nested sub-field) key is a path segment, not a literal — avoid literal dots
|
|
82
|
+
// in repeater/sub-field keys. (Leaf DXField keys, by contrast, are literal.)
|
|
83
|
+
const arrayPath = computed(() => props.keyPath ?? props.field.key);
|
|
84
|
+
const subFields = computed<FieldDefinition[]>(() => props.field.fields ?? []);
|
|
85
|
+
const minItems = computed(() => props.field.minItems ?? 0);
|
|
86
|
+
const maxItems = computed(() => props.field.maxItems);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The live rows array. Ensures form.data holds an array at the path so
|
|
90
|
+
* push/splice are reactive even when the form was seeded without one.
|
|
91
|
+
*/
|
|
92
|
+
const rows = computed<any[]>(() => {
|
|
93
|
+
const existing = getByPath(props.form.data, arrayPath.value);
|
|
94
|
+
if (Array.isArray(existing)) return existing;
|
|
95
|
+
return [];
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const rowPath = (index: number): string => `${arrayPath.value}.${index}`;
|
|
99
|
+
|
|
100
|
+
// Stable v-for keys tied to row object identity, so removing a middle row
|
|
101
|
+
// preserves each surviving row's DOM/component state (focus, file inputs)
|
|
102
|
+
// instead of shifting it down with the index. A WeakMap keeps the key off
|
|
103
|
+
// form.data, so it never leaks into the submitted payload. A persisted row
|
|
104
|
+
// id (when present) is preferred as it survives reloads.
|
|
105
|
+
const generatedKeys = new WeakMap<object, number>();
|
|
106
|
+
let nextGeneratedKey = 0;
|
|
107
|
+
|
|
108
|
+
const rowKey = (index: number): string | number => {
|
|
109
|
+
const row = rows.value[index];
|
|
110
|
+
if (row && typeof row === "object") {
|
|
111
|
+
if (row.id !== undefined && row.id !== null) return row.id;
|
|
112
|
+
let key = generatedKeys.get(row);
|
|
113
|
+
if (key === undefined) {
|
|
114
|
+
key = nextGeneratedKey;
|
|
115
|
+
nextGeneratedKey += 1;
|
|
116
|
+
generatedKeys.set(row, key);
|
|
117
|
+
}
|
|
118
|
+
return `gen-${key}`;
|
|
119
|
+
}
|
|
120
|
+
return index;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Default value for a freshly added row's sub-field. */
|
|
124
|
+
function defaultForType(type: FieldType): any {
|
|
125
|
+
switch (type) {
|
|
126
|
+
case "checkbox":
|
|
127
|
+
return false;
|
|
128
|
+
case "number":
|
|
129
|
+
case "currency":
|
|
130
|
+
case "percentage":
|
|
131
|
+
return 0;
|
|
132
|
+
case "repeater":
|
|
133
|
+
return [];
|
|
134
|
+
case "image":
|
|
135
|
+
case "file":
|
|
136
|
+
return null;
|
|
137
|
+
default:
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deep-clone a sub-field default so object/array defaults aren't shared
|
|
143
|
+
* (by reference) across rows. Uses JSON (not structuredClone) because the
|
|
144
|
+
* default may be a Vue reactive proxy — which structuredClone can't clone —
|
|
145
|
+
* and form defaults are JSON-serializable by construction. */
|
|
146
|
+
function cloneDefault(value: any): any {
|
|
147
|
+
if (value === null || typeof value !== "object") return value;
|
|
148
|
+
return JSON.parse(JSON.stringify(value));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildRow(): Record<string, any> {
|
|
152
|
+
const row: Record<string, any> = {};
|
|
153
|
+
for (const subField of subFields.value) {
|
|
154
|
+
row[subField.key] =
|
|
155
|
+
subField.default !== undefined
|
|
156
|
+
? cloneDefault(subField.default)
|
|
157
|
+
: defaultForType(subField.type);
|
|
158
|
+
}
|
|
159
|
+
return row;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Clear any validation errors keyed under this array (e.g. `lines.1.price`).
|
|
164
|
+
* Row add/remove shifts indices, so stale per-index errors would otherwise
|
|
165
|
+
* attach to the wrong row. They're re-populated on the next submit.
|
|
166
|
+
*/
|
|
167
|
+
function clearArrayErrors(): void {
|
|
168
|
+
const prefix = `${arrayPath.value}.`;
|
|
169
|
+
for (const key of Object.keys(props.form.errors)) {
|
|
170
|
+
if (key === arrayPath.value || key.startsWith(prefix)) {
|
|
171
|
+
props.form.clearError(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addRow(): void {
|
|
177
|
+
if (maxItems.value !== undefined && rows.value.length >= maxItems.value) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Ensure the array exists on form.data before pushing (path may be
|
|
181
|
+
// nested for repeaters inside repeater rows).
|
|
182
|
+
const existing = getByPath(props.form.data, arrayPath.value);
|
|
183
|
+
if (!Array.isArray(existing)) {
|
|
184
|
+
setByPath(props.form.data, arrayPath.value, []);
|
|
185
|
+
}
|
|
186
|
+
getByPath(props.form.data, arrayPath.value).push(buildRow());
|
|
187
|
+
clearArrayErrors();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function removeRow(index: number): void {
|
|
191
|
+
if (rows.value.length <= minItems.value) return;
|
|
192
|
+
getByPath(props.form.data, arrayPath.value).splice(index, 1);
|
|
193
|
+
clearArrayErrors();
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<style scoped>
|
|
198
|
+
.dx-repeater-row {
|
|
199
|
+
border: 1px solid var(--bs-border-color);
|
|
200
|
+
border-radius: var(--bs-border-radius);
|
|
201
|
+
padding: 1rem;
|
|
202
|
+
margin-bottom: 0.75rem;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.dx-repeater-row-header {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: space-between;
|
|
209
|
+
margin-bottom: 0.75rem;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.dx-repeater-row-index {
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
color: var(--bs-secondary-color);
|
|
215
|
+
}
|
|
216
|
+
</style>
|