@omnitend/dashboard-for-laravel 0.16.3 → 0.17.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/extended/DXField.vue.d.ts +75 -25
- package/dist/components/extended/DXForm.vue.d.ts +23 -1
- package/dist/components/extended/DXRepeater.vue.d.ts +16 -0
- package/dist/composables/useToast.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +8952 -8542
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +50 -1
- package/docs/public/api-reference.json +2 -2
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +28 -13
- package/resources/js/components/extended/DXField.vue +154 -7
- package/resources/js/components/extended/DXForm.vue +204 -80
- package/resources/js/components/extended/DXRepeater.vue +270 -4
- package/resources/js/composables/useToast.ts +53 -1
- package/resources/js/types/index.ts +52 -1
|
@@ -24,99 +24,197 @@
|
|
|
24
24
|
</DAlert>
|
|
25
25
|
|
|
26
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
:
|
|
44
|
-
|
|
45
|
-
:model="model"
|
|
46
|
-
/>
|
|
47
|
-
|
|
48
|
-
<div v-else class="pt-3">
|
|
27
|
+
(plain v-model is the active tab id), which is what we track.
|
|
28
|
+
`card` needs BOTH: DTabs' own `card` prop (adds `card-header`/
|
|
29
|
+
`card-body` classes to its nav/content internally) AND an outer
|
|
30
|
+
`.card` element wrapping it (BVN's `card` prop does not add the
|
|
31
|
+
outer wrapper itself — the consumer supplies it, per BVN's docs
|
|
32
|
+
pattern). `no-body` stops DCard from adding its own
|
|
33
|
+
`.card-body` wrapper — DTabs already provides `.card-header`/
|
|
34
|
+
`.card-body` internally via its own `card` prop, so wrapping
|
|
35
|
+
that in another `.card-body` would double up. -->
|
|
36
|
+
<component :is="card ? DCard : 'div'" v-if="hasTabs" v-bind="card ? { noBody: true } : {}">
|
|
37
|
+
<DTabs v-model:index="activeTab" :card="card">
|
|
38
|
+
<DTab
|
|
39
|
+
v-for="(tab, index) in visibleTabs"
|
|
40
|
+
:key="tab.key"
|
|
41
|
+
:title="resolveTabLabel(tab)"
|
|
42
|
+
:lazy="tab.lazy"
|
|
43
|
+
:active="index === 0"
|
|
44
|
+
>
|
|
49
45
|
<!--
|
|
50
|
-
@slot
|
|
46
|
+
@slot Replaces the entire body of a tab, keyed by tab (slot name `tab-content(<tabKey>)`).
|
|
51
47
|
@binding {FormTab} tab The tab definition being rendered.
|
|
52
48
|
@binding {object} model Live form data merged with `context`, for predicates.
|
|
53
49
|
-->
|
|
54
|
-
<slot
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
:key="field.key"
|
|
59
|
-
:field="field"
|
|
60
|
-
:form="resolvedForm"
|
|
50
|
+
<slot
|
|
51
|
+
v-if="$slots[`tab-content(${tab.key})`]"
|
|
52
|
+
:name="`tab-content(${tab.key})`"
|
|
53
|
+
:tab="tab"
|
|
61
54
|
:model="model"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<div v-else class="pt-3">
|
|
58
|
+
<!--
|
|
59
|
+
@slot Content inserted above a tab's fields, keyed by tab (slot name `tab-before(<tabKey>)`).
|
|
60
|
+
@binding {FormTab} tab The tab definition being rendered.
|
|
61
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
62
|
+
-->
|
|
63
|
+
<slot :name="`tab-before(${tab.key})`" :tab="tab" :model="model" />
|
|
64
|
+
|
|
65
|
+
<template v-for="field in visibleFieldsFor(tab)" :key="field.key">
|
|
66
|
+
<!--
|
|
67
|
+
@slot Fully replaces a field's rendering (including its label), keyed by field key: `field(<key>)`. Unlike `value(<key>)`, bypasses DXField entirely — like `tab-content`, this also supersedes `field-before(<key>)`/`field-after(<key>)` for the same key.
|
|
68
|
+
@binding {FieldDefinition} field The field definition being replaced.
|
|
69
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
70
|
+
-->
|
|
71
|
+
<slot
|
|
72
|
+
v-if="$slots[`field(${field.key})`]"
|
|
73
|
+
:name="`field(${field.key})`"
|
|
74
|
+
:field="field"
|
|
75
|
+
:model="model"
|
|
76
|
+
/>
|
|
77
|
+
<template v-else>
|
|
78
|
+
<!--
|
|
79
|
+
@slot Content inserted directly above a field, keyed by field key: `field-before(<key>)`. Not rendered when `field(<key>)` replaces the same field.
|
|
80
|
+
@binding {FieldDefinition} field The field about to be rendered.
|
|
81
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
82
|
+
-->
|
|
83
|
+
<slot :name="`field-before(${field.key})`" :field="field" :model="model" />
|
|
84
|
+
|
|
85
|
+
<DXField
|
|
86
|
+
:field="field"
|
|
87
|
+
:form="resolvedForm"
|
|
88
|
+
:model="model"
|
|
89
|
+
:layout="field.layout ?? layout"
|
|
90
|
+
:label-cols="field.labelCols ?? labelCols"
|
|
91
|
+
>
|
|
92
|
+
<template
|
|
93
|
+
v-for="(slotName, target) in fieldSlotMap(field.key)"
|
|
94
|
+
:key="target"
|
|
95
|
+
#[target]="slotProps"
|
|
96
|
+
>
|
|
97
|
+
<!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
|
|
98
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
99
|
+
</template>
|
|
100
|
+
</DXField>
|
|
101
|
+
|
|
102
|
+
<!--
|
|
103
|
+
@slot Content inserted directly below a field, keyed by field key: `field-after(<key>)`. Not rendered when `field(<key>)` replaces the same field.
|
|
104
|
+
@binding {FieldDefinition} field The field that was just rendered.
|
|
105
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
106
|
+
-->
|
|
107
|
+
<slot :name="`field-after(${field.key})`" :field="field" :model="model" />
|
|
108
|
+
</template>
|
|
70
109
|
</template>
|
|
71
|
-
</DXField>
|
|
72
110
|
|
|
111
|
+
<!--
|
|
112
|
+
@slot Content inserted below a tab's fields, keyed by tab (slot name `tab-after(<tabKey>)`).
|
|
113
|
+
@binding {FormTab} tab The tab definition being rendered.
|
|
114
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
115
|
+
-->
|
|
116
|
+
<slot :name="`tab-after(${tab.key})`" :tab="tab" :model="model" />
|
|
117
|
+
</div>
|
|
118
|
+
</DTab>
|
|
119
|
+
</DTabs>
|
|
120
|
+
</component>
|
|
121
|
+
|
|
122
|
+
<!-- Flat layout (no tabs). When `card` is set, the fields + submit
|
|
123
|
+
button + footer render inside a DCard, giving the form a visual
|
|
124
|
+
boundary (the tabbed case gets this from DTabs' own `card` prop
|
|
125
|
+
instead, above). -->
|
|
126
|
+
<template v-if="!hasTabs">
|
|
127
|
+
<component :is="card ? DCard : 'div'">
|
|
128
|
+
<template v-for="field in visibleFlatFields" :key="field.key">
|
|
73
129
|
<!--
|
|
74
|
-
@slot
|
|
75
|
-
@binding {
|
|
130
|
+
@slot Fully replaces a field's rendering (including its label), keyed by field key: `field(<key>)`. Unlike `value(<key>)`, bypasses DXField entirely — like `tab-content`, this also supersedes `field-before(<key>)`/`field-after(<key>)` for the same key.
|
|
131
|
+
@binding {FieldDefinition} field The field definition being replaced.
|
|
76
132
|
@binding {object} model Live form data merged with `context`, for predicates.
|
|
77
133
|
-->
|
|
78
|
-
<slot
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
134
|
+
<slot
|
|
135
|
+
v-if="$slots[`field(${field.key})`]"
|
|
136
|
+
:name="`field(${field.key})`"
|
|
137
|
+
:field="field"
|
|
138
|
+
:model="model"
|
|
139
|
+
/>
|
|
140
|
+
<template v-else>
|
|
141
|
+
<!--
|
|
142
|
+
@slot Content inserted directly above a field, keyed by field key: `field-before(<key>)`. Not rendered when `field(<key>)` replaces the same field.
|
|
143
|
+
@binding {FieldDefinition} field The field about to be rendered.
|
|
144
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
145
|
+
-->
|
|
146
|
+
<slot :name="`field-before(${field.key})`" :field="field" :model="model" />
|
|
147
|
+
|
|
148
|
+
<DXField
|
|
149
|
+
:field="field"
|
|
150
|
+
:form="resolvedForm"
|
|
151
|
+
:model="model"
|
|
152
|
+
:layout="field.layout ?? layout"
|
|
153
|
+
:label-cols="field.labelCols ?? labelCols"
|
|
154
|
+
>
|
|
155
|
+
<template
|
|
156
|
+
v-for="(slotName, target) in fieldSlotMap(field.key)"
|
|
157
|
+
:key="target"
|
|
158
|
+
#[target]="slotProps"
|
|
159
|
+
>
|
|
160
|
+
<!-- @slot Per-field overrides forwarded to DXField, keyed by field key: `value(<key>)`, `span(<key>)`, `info(<key>)`, `hint(<key>)`, `repeater-row(<key>)`. -->
|
|
161
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
162
|
+
</template>
|
|
163
|
+
</DXField>
|
|
164
|
+
|
|
165
|
+
<!--
|
|
166
|
+
@slot Content inserted directly below a field, keyed by field key: `field-after(<key>)`. Not rendered when `field(<key>)` replaces the same field.
|
|
167
|
+
@binding {FieldDefinition} field The field that was just rendered.
|
|
168
|
+
@binding {object} model Live form data merged with `context`, for predicates.
|
|
169
|
+
-->
|
|
170
|
+
<slot :name="`field-after(${field.key})`" :field="field" :model="model" />
|
|
171
|
+
</template>
|
|
172
|
+
</template>
|
|
82
173
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
:model="model"
|
|
91
|
-
>
|
|
92
|
-
<template
|
|
93
|
-
v-for="(slotName, target) in fieldSlotMap(field.key)"
|
|
94
|
-
:key="target"
|
|
95
|
-
#[target]="slotProps"
|
|
174
|
+
<!-- Submit button -->
|
|
175
|
+
<DButton
|
|
176
|
+
v-if="showSubmit"
|
|
177
|
+
type="submit"
|
|
178
|
+
variant="primary"
|
|
179
|
+
:disabled="resolvedForm.processing"
|
|
180
|
+
class="w-100 mt-3"
|
|
96
181
|
>
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
</
|
|
100
|
-
|
|
182
|
+
<span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
|
|
183
|
+
<span v-else>{{ submitText }}</span>
|
|
184
|
+
</DButton>
|
|
185
|
+
|
|
186
|
+
<!--
|
|
187
|
+
@slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
|
|
188
|
+
@binding {UseFormReturn} form The resolved form instance (state, errors, submit helpers).
|
|
189
|
+
-->
|
|
190
|
+
<slot name="footer" :form="resolvedForm" />
|
|
191
|
+
</component>
|
|
101
192
|
</template>
|
|
102
193
|
|
|
103
|
-
<!--
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
194
|
+
<!-- Tabbed layout: submit button + footer always render as siblings
|
|
195
|
+
below DTabs (outside the card, when `card` is set) — unlike the
|
|
196
|
+
flat layout, they aren't pulled inside the card here, since
|
|
197
|
+
DTabs' own card-body padding belongs to its tab content, not to
|
|
198
|
+
trailing form-level actions. -->
|
|
199
|
+
<template v-else>
|
|
200
|
+
<!-- Submit button -->
|
|
201
|
+
<DButton
|
|
202
|
+
v-if="showSubmit"
|
|
203
|
+
type="submit"
|
|
204
|
+
variant="primary"
|
|
205
|
+
:disabled="resolvedForm.processing"
|
|
206
|
+
class="w-100 mt-3"
|
|
207
|
+
>
|
|
208
|
+
<span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
|
|
209
|
+
<span v-else>{{ submitText }}</span>
|
|
210
|
+
</DButton>
|
|
211
|
+
|
|
212
|
+
<!--
|
|
213
|
+
@slot Content rendered below the submit button (e.g. a cancel link or secondary actions).
|
|
214
|
+
@binding {UseFormReturn} form The resolved form instance (state, errors, submit helpers).
|
|
215
|
+
-->
|
|
216
|
+
<slot name="footer" :form="resolvedForm" />
|
|
217
|
+
</template>
|
|
120
218
|
</BForm>
|
|
121
219
|
</template>
|
|
122
220
|
|
|
@@ -125,12 +223,13 @@ import { computed, watch } from "vue";
|
|
|
125
223
|
import { BForm } from "bootstrap-vue-next";
|
|
126
224
|
import DAlert from "../base/DAlert.vue";
|
|
127
225
|
import DButton from "../base/DButton.vue";
|
|
226
|
+
import DCard from "../base/DCard.vue";
|
|
128
227
|
import DTabs from "../base/DTabs.vue";
|
|
129
228
|
import DTab from "../base/DTab.vue";
|
|
130
229
|
import DXField from "./DXField.vue";
|
|
131
230
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
132
231
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
133
|
-
import type { FieldDefinition, FormTab, MaybeFn } from "../../types";
|
|
232
|
+
import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
|
|
134
233
|
|
|
135
234
|
interface Props {
|
|
136
235
|
/**
|
|
@@ -164,6 +263,29 @@ interface Props {
|
|
|
164
263
|
|
|
165
264
|
/** Auto-switch to the first tab containing a validation error. */
|
|
166
265
|
autoErrorTab?: boolean;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Wrap the form in a card for a visual boundary (mirrors DXTable's
|
|
269
|
+
* `card` prop). Tabbed forms render the tab nav as a BS5
|
|
270
|
+
* card-header-tabs instead of double-wrapping. Off by default since
|
|
271
|
+
* DXForm is commonly embedded in a page card or modal already.
|
|
272
|
+
*/
|
|
273
|
+
card?: boolean;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Form-wide field layout: "vertical" (default, label above input) or
|
|
277
|
+
* "horizontal" (label left, input right). Overridable per-field via
|
|
278
|
+
* `field.layout`. A field with `span: true` always renders full-width,
|
|
279
|
+
* regardless of layout.
|
|
280
|
+
*/
|
|
281
|
+
layout?: "vertical" | "horizontal";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Label column width for horizontal layout (mirrors BFormGroup's
|
|
285
|
+
* `labelCols`/`labelCols*` props). Overridable per-field via
|
|
286
|
+
* `field.labelCols`. Ignored when `layout` is "vertical".
|
|
287
|
+
*/
|
|
288
|
+
labelCols?: LabelCols;
|
|
167
289
|
}
|
|
168
290
|
|
|
169
291
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -171,6 +293,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
171
293
|
submitLoadingText: "Submitting...",
|
|
172
294
|
showSubmit: true,
|
|
173
295
|
autoErrorTab: true,
|
|
296
|
+
card: false,
|
|
297
|
+
layout: "vertical",
|
|
174
298
|
});
|
|
175
299
|
|
|
176
300
|
const emit = defineEmits<{
|
|
@@ -5,7 +5,157 @@
|
|
|
5
5
|
field's `minItems`/`maxItems` limits.
|
|
6
6
|
-->
|
|
7
7
|
<template>
|
|
8
|
-
|
|
8
|
+
<!-- `repeaterLayout: "table"` is a PREFERENCE, not a guarantee: the table
|
|
9
|
+
needs real horizontal room for its columns, and the same viewport
|
|
10
|
+
width can put this repeater in a wide standalone form or a narrow
|
|
11
|
+
sidebar column — a viewport breakpoint can't tell those apart. A
|
|
12
|
+
ResizeObserver on THIS OUTER wrapper measures the space actually
|
|
13
|
+
available and falls back to the cards layout when it's not enough,
|
|
14
|
+
scaling to this repeater's own column count (see
|
|
15
|
+
`neededTableWidth`/`showTable` below). This outer wrapper is never
|
|
16
|
+
hidden (an element measures 0×0 once `display:none`, which would
|
|
17
|
+
leave a hidden table with no way to detect regained space and
|
|
18
|
+
un-hide itself) — but its two children below ARE mutually exclusive
|
|
19
|
+
via `v-if`/`v-else`, not `v-show`: only ever ONE of table/cards is
|
|
20
|
+
actually mounted, so a field with side effects (e.g. an async
|
|
21
|
+
`optionsLoader`) doesn't run twice forever just because table mode
|
|
22
|
+
is configured. -->
|
|
23
|
+
<div v-if="wantsTableLayout" ref="containerRef" class="dx-repeater-container">
|
|
24
|
+
<div v-if="showTable" class="dx-repeater-table-wrapper">
|
|
25
|
+
<table class="dx-repeater-table table">
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
<th v-for="subField in subFields" :key="subField.key">
|
|
29
|
+
{{ headerLabel(subField) }}
|
|
30
|
+
</th>
|
|
31
|
+
<th class="dx-repeater-table-remove-col"></th>
|
|
32
|
+
</tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody>
|
|
35
|
+
<tr
|
|
36
|
+
v-for="(entry, position) in visibleRows"
|
|
37
|
+
:key="rowKey(entry.row, position)"
|
|
38
|
+
>
|
|
39
|
+
<!--
|
|
40
|
+
@slot Custom layout for a single repeater row, replacing the default sub-field stack (including the built-in Remove control — call `remove()` yourself). In table layout, rendered as the `<tr>`'s children: supply one `<td>` per sub-field plus a trailing `<td>` to match the header's column count (the header always includes an empty column for the built-in remove button).
|
|
41
|
+
@binding {object} row The current row's data object.
|
|
42
|
+
@binding {number} index The row's zero-based position among visible rows.
|
|
43
|
+
@binding {FieldDefinition[]} fields The sub-field definitions for the row.
|
|
44
|
+
@binding {function} remove Removes this row (respects `minItems`; soft-deletes instead of splicing when `field.softDeleteKey` is set and the row has an `id`).
|
|
45
|
+
@binding {string} path The dot path into `form.data` for this row (e.g. `lines.0`).
|
|
46
|
+
-->
|
|
47
|
+
<slot
|
|
48
|
+
v-if="$slots.row"
|
|
49
|
+
name="row"
|
|
50
|
+
:row="entry.row"
|
|
51
|
+
:index="position"
|
|
52
|
+
:fields="subFields"
|
|
53
|
+
:remove="() => removeRow(entry.index)"
|
|
54
|
+
:path="rowPath(entry.index)"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<template v-else>
|
|
58
|
+
<td v-for="subField in subFields" :key="subField.key">
|
|
59
|
+
<DXField
|
|
60
|
+
:field="subField"
|
|
61
|
+
:form="form"
|
|
62
|
+
:model="entry.row"
|
|
63
|
+
:key-path="`${rowPath(entry.index)}.${subField.key}`"
|
|
64
|
+
:error-key="`${rowPath(entry.index)}.${subField.key}`"
|
|
65
|
+
hide-label
|
|
66
|
+
/>
|
|
67
|
+
</td>
|
|
68
|
+
<td class="dx-repeater-table-remove-col">
|
|
69
|
+
<DButton
|
|
70
|
+
variant="outline-danger"
|
|
71
|
+
class="dx-repeater-table-remove-btn"
|
|
72
|
+
:disabled="visibleRows.length <= minItems"
|
|
73
|
+
:aria-label="`Remove row ${position + 1}`"
|
|
74
|
+
@click="removeRow(entry.index)"
|
|
75
|
+
>
|
|
76
|
+
<i-lucide-trash-2 aria-hidden="true" />
|
|
77
|
+
</DButton>
|
|
78
|
+
</td>
|
|
79
|
+
</template>
|
|
80
|
+
</tr>
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
|
|
84
|
+
<DButton
|
|
85
|
+
variant="outline-primary"
|
|
86
|
+
size="sm"
|
|
87
|
+
:disabled="maxItems !== undefined && visibleRows.length >= maxItems"
|
|
88
|
+
@click="addRow"
|
|
89
|
+
>
|
|
90
|
+
{{ field.addLabel || "Add" }}
|
|
91
|
+
</DButton>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<!-- Cards fallback: identical to the default cards markup below,
|
|
95
|
+
shown instead of the table when there isn't enough room for it.
|
|
96
|
+
`v-else` (not `v-show`) — only the OUTER wrapper above needs to
|
|
97
|
+
stay mounted for measurement; unmounting whichever of these two
|
|
98
|
+
isn't current avoids permanently double-running every DXField
|
|
99
|
+
in both layouts at once (double async optionsLoader fetches,
|
|
100
|
+
double watchers, double everything else a field might do). -->
|
|
101
|
+
<div v-else class="dx-repeater">
|
|
102
|
+
<div
|
|
103
|
+
v-for="(entry, position) in visibleRows"
|
|
104
|
+
:key="rowKey(entry.row, position)"
|
|
105
|
+
class="dx-repeater-row"
|
|
106
|
+
>
|
|
107
|
+
<slot
|
|
108
|
+
v-if="$slots.row"
|
|
109
|
+
name="row"
|
|
110
|
+
:row="entry.row"
|
|
111
|
+
:index="position"
|
|
112
|
+
:fields="subFields"
|
|
113
|
+
:remove="() => removeRow(entry.index)"
|
|
114
|
+
:path="rowPath(entry.index)"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<template v-else>
|
|
118
|
+
<div class="dx-repeater-row-header">
|
|
119
|
+
<span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
|
|
120
|
+
<DButton
|
|
121
|
+
variant="outline-danger"
|
|
122
|
+
size="sm"
|
|
123
|
+
:disabled="visibleRows.length <= minItems"
|
|
124
|
+
@click="removeRow(entry.index)"
|
|
125
|
+
>
|
|
126
|
+
Remove
|
|
127
|
+
</DButton>
|
|
128
|
+
</div>
|
|
129
|
+
<DXField
|
|
130
|
+
v-for="subField in subFields"
|
|
131
|
+
:key="subField.key"
|
|
132
|
+
:field="subField"
|
|
133
|
+
:form="form"
|
|
134
|
+
:model="entry.row"
|
|
135
|
+
:key-path="`${rowPath(entry.index)}.${subField.key}`"
|
|
136
|
+
:error-key="`${rowPath(entry.index)}.${subField.key}`"
|
|
137
|
+
/>
|
|
138
|
+
</template>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<DButton
|
|
142
|
+
variant="outline-primary"
|
|
143
|
+
size="sm"
|
|
144
|
+
:disabled="maxItems !== undefined && visibleRows.length >= maxItems"
|
|
145
|
+
@click="addRow"
|
|
146
|
+
>
|
|
147
|
+
{{ field.addLabel || "Add" }}
|
|
148
|
+
</DButton>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<!-- Cards layout (default, `repeaterLayout: "cards"` or unset): each row
|
|
153
|
+
is its own bordered card with sub-fields stacked vertically. Kept as
|
|
154
|
+
a separate, unconditional branch (rather than reusing the fallback
|
|
155
|
+
above) so this — the overwhelmingly common case — never pays for a
|
|
156
|
+
ResizeObserver or the extra wrapper div; its markup is byte-for-byte
|
|
157
|
+
what cards mode has always rendered. -->
|
|
158
|
+
<div v-else class="dx-repeater">
|
|
9
159
|
<div
|
|
10
160
|
v-for="(entry, position) in visibleRows"
|
|
11
161
|
:key="rowKey(entry.row, position)"
|
|
@@ -32,7 +182,7 @@
|
|
|
32
182
|
<!-- Default row: stack each sub-field -->
|
|
33
183
|
<template v-else>
|
|
34
184
|
<div class="dx-repeater-row-header">
|
|
35
|
-
<span class="dx-repeater-row-index">{{ position + 1 }}</span>
|
|
185
|
+
<span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
|
|
36
186
|
<DButton
|
|
37
187
|
variant="outline-danger"
|
|
38
188
|
size="sm"
|
|
@@ -66,7 +216,7 @@
|
|
|
66
216
|
</template>
|
|
67
217
|
|
|
68
218
|
<script setup lang="ts">
|
|
69
|
-
import { computed } from "vue";
|
|
219
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
70
220
|
import DButton from "../base/DButton.vue";
|
|
71
221
|
import DXField from "./DXField.vue";
|
|
72
222
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
@@ -99,6 +249,79 @@ const minItems = computed(() => props.field.minItems ?? 0);
|
|
|
99
249
|
const maxItems = computed(() => props.field.maxItems);
|
|
100
250
|
const softDeleteKey = computed(() => props.field.softDeleteKey);
|
|
101
251
|
|
|
252
|
+
/** Column header text for a sub-field. No specific row context exists for a
|
|
253
|
+
* header, so a function-valued label resolves against the outer model. */
|
|
254
|
+
function headerLabel(subField: FieldDefinition): string {
|
|
255
|
+
const label = subField.label;
|
|
256
|
+
if (typeof label === "function") return label(props.model ?? {});
|
|
257
|
+
return label || subField.key;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ————————————————— table/cards responsive fallback (#68 follow-up)
|
|
261
|
+
|
|
262
|
+
const wantsTableLayout = computed(() => props.field.repeaterLayout === "table");
|
|
263
|
+
|
|
264
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
265
|
+
const containerWidth = ref<number | null>(null);
|
|
266
|
+
|
|
267
|
+
// Heuristic minimum width for the table to stay legible: per-sub-field
|
|
268
|
+
// column widths (not a pixel-measured fit, just a reasonable estimate —
|
|
269
|
+
// `currency`/`percentage` get extra room for their `£`/`%` affix, which
|
|
270
|
+
// otherwise crowds out the number itself) plus the trailing icon-only
|
|
271
|
+
// remove column. Scales with this repeater's own column count/types,
|
|
272
|
+
// unlike a single fixed viewport breakpoint.
|
|
273
|
+
const DEFAULT_COLUMN_WIDTH = 130;
|
|
274
|
+
const AFFIX_COLUMN_WIDTH = 160;
|
|
275
|
+
const REMOVE_COLUMN_WIDTH = 70;
|
|
276
|
+
|
|
277
|
+
function columnWidthFor(subField: FieldDefinition): number {
|
|
278
|
+
if (subField.type === "currency" || subField.type === "percentage") {
|
|
279
|
+
return AFFIX_COLUMN_WIDTH;
|
|
280
|
+
}
|
|
281
|
+
return DEFAULT_COLUMN_WIDTH;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const neededTableWidth = computed(
|
|
285
|
+
() =>
|
|
286
|
+
subFields.value.reduce((total, subField) => total + columnWidthFor(subField), 0) +
|
|
287
|
+
REMOVE_COLUMN_WIDTH,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// `null` (not yet measured) defaults to showing the table — the configured
|
|
291
|
+
// preference — rather than flashing cards first. Resolves as soon as the
|
|
292
|
+
// watcher below fires (see its comment for why that isn't simply onMounted).
|
|
293
|
+
const showTable = computed(
|
|
294
|
+
() => containerWidth.value === null || containerWidth.value >= neededTableWidth.value,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
298
|
+
|
|
299
|
+
// A `watch` on the ref itself, not `onMounted`: DXRepeater is loaded via
|
|
300
|
+
// `defineAsyncComponent` (DXField imports it lazily to break a circular
|
|
301
|
+
// import), and for an async-resolved component whose root element also
|
|
302
|
+
// carries `v-if`, the template ref is not always populated yet by the time
|
|
303
|
+
// `onMounted` fires — observed live as `containerRef.value === null` inside
|
|
304
|
+
// `onMounted` despite the element existing in the DOM at that point. `watch`
|
|
305
|
+
// instead reacts whenever the ref actually receives (or loses, e.g. if
|
|
306
|
+
// `wantsTableLayout` ever toggles false) its element, regardless of when
|
|
307
|
+
// that happens relative to lifecycle hooks.
|
|
308
|
+
watch(containerRef, (el) => {
|
|
309
|
+
resizeObserver?.disconnect();
|
|
310
|
+
resizeObserver = null;
|
|
311
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
312
|
+
containerWidth.value = el.getBoundingClientRect().width;
|
|
313
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
314
|
+
const entry = entries[0];
|
|
315
|
+
if (entry) containerWidth.value = entry.contentRect.width;
|
|
316
|
+
});
|
|
317
|
+
resizeObserver.observe(el);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
onBeforeUnmount(() => {
|
|
321
|
+
resizeObserver?.disconnect();
|
|
322
|
+
resizeObserver = null;
|
|
323
|
+
});
|
|
324
|
+
|
|
102
325
|
function isSoftDeleted(row: any): boolean {
|
|
103
326
|
return Boolean(
|
|
104
327
|
softDeleteKey.value &&
|
|
@@ -254,12 +477,55 @@ function removeRow(index: number): void {
|
|
|
254
477
|
.dx-repeater-row-header {
|
|
255
478
|
display: flex;
|
|
256
479
|
align-items: center;
|
|
257
|
-
justify-content: space-between;
|
|
258
480
|
margin-bottom: 0.75rem;
|
|
259
481
|
}
|
|
260
482
|
|
|
483
|
+
/* The Remove control is always this header's last child, whether or not the
|
|
484
|
+
(opt-in) row-index span is also present — push it to the end explicitly
|
|
485
|
+
rather than relying on `justify-content: space-between`, which centres a
|
|
486
|
+
single remaining child at the *start* once the index is off (the default). */
|
|
487
|
+
.dx-repeater-row-header > :last-child {
|
|
488
|
+
margin-inline-start: auto;
|
|
489
|
+
}
|
|
490
|
+
|
|
261
491
|
.dx-repeater-row-index {
|
|
262
492
|
font-weight: 600;
|
|
263
493
|
color: var(--bs-secondary-color);
|
|
264
494
|
}
|
|
495
|
+
|
|
496
|
+
.dx-repeater-table td {
|
|
497
|
+
vertical-align: middle;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* Sub-fields render with their default mb-3 (matching the standalone-field
|
|
501
|
+
default), which is unwanted spacing inside an already-compact table cell —
|
|
502
|
+
and leaves the field vertically off-centre against the trailing remove
|
|
503
|
+
button, since that residual margin-bottom is inside the td's own
|
|
504
|
+
vertical-align:middle box. Bootstrap's spacing utilities are `!important`
|
|
505
|
+
by design (same as the toast-variant override elsewhere in this project),
|
|
506
|
+
so overriding one requires `!important` too — a plain declaration silently
|
|
507
|
+
loses and the margin survives. */
|
|
508
|
+
.dx-repeater-table td :deep(.mb-3) {
|
|
509
|
+
margin-bottom: 0 !important;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.dx-repeater-table-remove-col {
|
|
513
|
+
width: 1%;
|
|
514
|
+
white-space: nowrap;
|
|
515
|
+
text-align: center;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* Match the row's text-input height (same var the switch field uses).
|
|
519
|
+
`--dx-input-height`'s `em` term resolves against the CONSUMING element's
|
|
520
|
+
own font-size, not the input's — so this only lines up if the button's
|
|
521
|
+
font-size equals .form-control's (both derive from Bootstrap's base
|
|
522
|
+
font-size var, but a `size="sm"` DButton sets a smaller one). The button
|
|
523
|
+
is deliberately NOT `size="sm"` here for that reason; min-height still
|
|
524
|
+
keeps it visually compact regardless. */
|
|
525
|
+
.dx-repeater-table-remove-btn {
|
|
526
|
+
display: inline-flex;
|
|
527
|
+
align-items: center;
|
|
528
|
+
justify-content: center;
|
|
529
|
+
min-height: var(--dx-input-height, calc(1.5em + 0.75rem + 2px));
|
|
530
|
+
}
|
|
265
531
|
</style>
|