@omnitend/dashboard-for-laravel 0.22.0 → 0.24.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/DXDashboard.vue.d.ts +12 -1
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +7 -2
- package/dist/components/extended/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXTable.vue.d.ts +55 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +40 -0
- package/dist/dashboard-for-laravel.js +9578 -9410
- 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 +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +44 -0
- package/docs/public/api-reference.json +120 -6
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -1
- package/resources/js/components/extended/DXDashboard.vue +42 -4
- package/resources/js/components/extended/DXDashboardNavbar.vue +5 -18
- package/resources/js/components/extended/DXField.vue +153 -26
- package/resources/js/components/extended/DXTable.vue +516 -193
- package/resources/js/components/extended/DXUserAvatar.vue +104 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +48 -0
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
<DRow class="justify-content-center">
|
|
11
11
|
<DCol :md="columnSize">
|
|
12
12
|
<DXTableShell :card="card">
|
|
13
|
-
<template v-if="title ||
|
|
13
|
+
<template v-if="title || showsCreateButton || $slots.header" #header>
|
|
14
14
|
<!-- @slot Card header content; overrides the default title heading and the "New {item}" button. -->
|
|
15
15
|
<slot name="header">
|
|
16
16
|
<div class="d-flex justify-content-between align-items-center">
|
|
17
17
|
<h4 class="mb-0">{{ title }}</h4>
|
|
18
18
|
<DButton
|
|
19
|
-
v-if="
|
|
19
|
+
v-if="showsCreateButton"
|
|
20
20
|
variant="primary"
|
|
21
21
|
size="sm"
|
|
22
22
|
@click="handleCreateNew"
|
|
@@ -27,15 +27,19 @@
|
|
|
27
27
|
</slot>
|
|
28
28
|
</template>
|
|
29
29
|
|
|
30
|
+
<!-- Rendered ABOVE the table, not instead of it: a failed
|
|
31
|
+
request is usually caused by the sort or a filter, and
|
|
32
|
+
replacing the table would take away the very controls
|
|
33
|
+
needed to undo it, leaving a reload as the only way out. -->
|
|
34
|
+
<div v-if="error || apiError" class="alert alert-danger">
|
|
35
|
+
{{ error || apiError }}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
30
38
|
<div v-if="effectiveBusy && !isProviderMode" class="text-center py-5">
|
|
31
39
|
<DSpinner variant="primary" />
|
|
32
40
|
<p class="mt-2">{{ loadingText }}</p>
|
|
33
41
|
</div>
|
|
34
42
|
|
|
35
|
-
<div v-else-if="error || apiError" class="alert alert-danger">
|
|
36
|
-
{{ error || apiError }}
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
43
|
<!-- Provider Mode: Use BTable's provider pattern -->
|
|
40
44
|
<DTable
|
|
41
45
|
v-else-if="isProviderMode"
|
|
@@ -51,7 +55,9 @@
|
|
|
51
55
|
:hover="hover"
|
|
52
56
|
:responsive="responsive"
|
|
53
57
|
:busy="busy"
|
|
58
|
+
v-bind="tablePassthroughProps"
|
|
54
59
|
@update:sort-by="handleSortChange"
|
|
60
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
55
61
|
@update:current-page="apiCurrentPage = $event"
|
|
56
62
|
@update:busy="handleBusyChange"
|
|
57
63
|
@row-clicked="handleRowClick"
|
|
@@ -63,40 +69,40 @@
|
|
|
63
69
|
<!-- Text Filter -->
|
|
64
70
|
<DFormInput
|
|
65
71
|
v-if="field.filter === 'text'"
|
|
66
|
-
:model-value="effectiveFilters[field
|
|
72
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
67
73
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
68
74
|
size="sm"
|
|
69
|
-
@update:model-value="handleFilterChange(field
|
|
75
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
70
76
|
/>
|
|
71
77
|
|
|
72
78
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
73
79
|
<DAutocomplete
|
|
74
80
|
v-else-if="field.filter === 'select'"
|
|
75
|
-
:model-value="effectiveFilters[field
|
|
81
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
76
82
|
:options="getFieldFilterOptions(field)"
|
|
77
83
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
78
84
|
size="sm"
|
|
79
85
|
open-on-focus
|
|
80
|
-
@update:model-value="handleFilterChange(field
|
|
86
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
81
87
|
/>
|
|
82
88
|
|
|
83
89
|
<!-- Number Filter -->
|
|
84
90
|
<DFormInput
|
|
85
91
|
v-else-if="field.filter === 'number'"
|
|
86
|
-
:model-value="effectiveFilters[field
|
|
92
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
87
93
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
88
94
|
type="number"
|
|
89
95
|
size="sm"
|
|
90
|
-
@update:model-value="handleFilterChange(field
|
|
96
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
91
97
|
/>
|
|
92
98
|
|
|
93
99
|
<!-- Date Filter -->
|
|
94
100
|
<DFormInput
|
|
95
101
|
v-else-if="field.filter === 'date'"
|
|
96
|
-
:model-value="effectiveFilters[field
|
|
102
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
97
103
|
type="date"
|
|
98
104
|
size="sm"
|
|
99
|
-
@update:model-value="handleFilterChange(field
|
|
105
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
100
106
|
/>
|
|
101
107
|
|
|
102
108
|
<!-- No filter for this column -->
|
|
@@ -109,7 +115,7 @@
|
|
|
109
115
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
110
116
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
111
117
|
<div class="flex-grow-1">
|
|
112
|
-
<div class="fw-semibold">{{ label
|
|
118
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
113
119
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
114
120
|
</div>
|
|
115
121
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -119,23 +125,19 @@
|
|
|
119
125
|
</div>
|
|
120
126
|
</template>
|
|
121
127
|
|
|
122
|
-
<!--
|
|
128
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
129
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
130
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
131
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
123
132
|
<template
|
|
124
|
-
v-for="
|
|
133
|
+
v-for="name in forwardedTableSlotNames"
|
|
134
|
+
:key="name"
|
|
125
135
|
#[name]="slotProps"
|
|
126
136
|
>
|
|
127
137
|
<!--
|
|
128
|
-
@slot
|
|
129
|
-
@binding {object} item The row data object for this cell.
|
|
130
|
-
@binding {any} value The cell value for the field.
|
|
131
|
-
@binding {number} index The zero-based row index.
|
|
132
|
-
@binding {object} field The field definition for the column.
|
|
138
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
133
139
|
-->
|
|
134
|
-
<slot
|
|
135
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
136
|
-
:name="name"
|
|
137
|
-
v-bind="slotProps"
|
|
138
|
-
/>
|
|
140
|
+
<slot :name="name" v-bind="slotProps" />
|
|
139
141
|
</template>
|
|
140
142
|
</DTable>
|
|
141
143
|
|
|
@@ -151,7 +153,9 @@
|
|
|
151
153
|
:striped="striped"
|
|
152
154
|
:hover="hover"
|
|
153
155
|
:responsive="responsive"
|
|
156
|
+
v-bind="tablePassthroughProps"
|
|
154
157
|
@update:sort-by="handleSortChange"
|
|
158
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
155
159
|
@row-clicked="handleRowClick"
|
|
156
160
|
>
|
|
157
161
|
<!-- Inline Filter Row -->
|
|
@@ -161,40 +165,40 @@
|
|
|
161
165
|
<!-- Text Filter -->
|
|
162
166
|
<DFormInput
|
|
163
167
|
v-if="field.filter === 'text'"
|
|
164
|
-
:model-value="effectiveFilters[field
|
|
168
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
165
169
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
166
170
|
size="sm"
|
|
167
|
-
@update:model-value="handleFilterChange(field
|
|
171
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
168
172
|
/>
|
|
169
173
|
|
|
170
174
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
171
175
|
<DAutocomplete
|
|
172
176
|
v-else-if="field.filter === 'select'"
|
|
173
|
-
:model-value="effectiveFilters[field
|
|
177
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
174
178
|
:options="getFieldFilterOptions(field)"
|
|
175
179
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
176
180
|
size="sm"
|
|
177
181
|
open-on-focus
|
|
178
|
-
@update:model-value="handleFilterChange(field
|
|
182
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
179
183
|
/>
|
|
180
184
|
|
|
181
185
|
<!-- Number Filter -->
|
|
182
186
|
<DFormInput
|
|
183
187
|
v-else-if="field.filter === 'number'"
|
|
184
|
-
:model-value="effectiveFilters[field
|
|
188
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
185
189
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
186
190
|
type="number"
|
|
187
191
|
size="sm"
|
|
188
|
-
@update:model-value="handleFilterChange(field
|
|
192
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
189
193
|
/>
|
|
190
194
|
|
|
191
195
|
<!-- Date Filter -->
|
|
192
196
|
<DFormInput
|
|
193
197
|
v-else-if="field.filter === 'date'"
|
|
194
|
-
:model-value="effectiveFilters[field
|
|
198
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
195
199
|
type="date"
|
|
196
200
|
size="sm"
|
|
197
|
-
@update:model-value="handleFilterChange(field
|
|
201
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
198
202
|
/>
|
|
199
203
|
|
|
200
204
|
<!-- No filter for this column -->
|
|
@@ -207,7 +211,7 @@
|
|
|
207
211
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
208
212
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
209
213
|
<div class="flex-grow-1">
|
|
210
|
-
<div class="fw-semibold">{{ label
|
|
214
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
211
215
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
212
216
|
</div>
|
|
213
217
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -217,23 +221,19 @@
|
|
|
217
221
|
</div>
|
|
218
222
|
</template>
|
|
219
223
|
|
|
220
|
-
<!--
|
|
224
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
225
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
226
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
227
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
221
228
|
<template
|
|
222
|
-
v-for="
|
|
229
|
+
v-for="name in forwardedTableSlotNames"
|
|
230
|
+
:key="name"
|
|
223
231
|
#[name]="slotProps"
|
|
224
232
|
>
|
|
225
233
|
<!--
|
|
226
|
-
@slot
|
|
227
|
-
@binding {object} item The row data object for this cell.
|
|
228
|
-
@binding {any} value The cell value for the field.
|
|
229
|
-
@binding {number} index The zero-based row index.
|
|
230
|
-
@binding {object} field The field definition for the column.
|
|
234
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
231
235
|
-->
|
|
232
|
-
<slot
|
|
233
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
234
|
-
:name="name"
|
|
235
|
-
v-bind="slotProps"
|
|
236
|
-
/>
|
|
236
|
+
<slot :name="name" v-bind="slotProps" />
|
|
237
237
|
</template>
|
|
238
238
|
</DTable>
|
|
239
239
|
|
|
@@ -250,7 +250,9 @@
|
|
|
250
250
|
:hover="hover"
|
|
251
251
|
:responsive="responsive"
|
|
252
252
|
:busy="effectiveBusy"
|
|
253
|
+
v-bind="tablePassthroughProps"
|
|
253
254
|
@update:sort-by="handleSortChange"
|
|
255
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
254
256
|
@row-clicked="handleRowClick"
|
|
255
257
|
>
|
|
256
258
|
<!-- Inline Filter Row -->
|
|
@@ -260,40 +262,40 @@
|
|
|
260
262
|
<!-- Text Filter -->
|
|
261
263
|
<DFormInput
|
|
262
264
|
v-if="field.filter === 'text'"
|
|
263
|
-
:model-value="effectiveFilters[field
|
|
265
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
264
266
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
265
267
|
size="sm"
|
|
266
|
-
@update:model-value="handleFilterChange(field
|
|
268
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
267
269
|
/>
|
|
268
270
|
|
|
269
271
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
270
272
|
<DAutocomplete
|
|
271
273
|
v-else-if="field.filter === 'select'"
|
|
272
|
-
:model-value="effectiveFilters[field
|
|
274
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
273
275
|
:options="getFieldFilterOptions(field)"
|
|
274
276
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
275
277
|
size="sm"
|
|
276
278
|
open-on-focus
|
|
277
|
-
@update:model-value="handleFilterChange(field
|
|
279
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
278
280
|
/>
|
|
279
281
|
|
|
280
282
|
<!-- Number Filter -->
|
|
281
283
|
<DFormInput
|
|
282
284
|
v-else-if="field.filter === 'number'"
|
|
283
|
-
:model-value="effectiveFilters[field
|
|
285
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
284
286
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
285
287
|
type="number"
|
|
286
288
|
size="sm"
|
|
287
|
-
@update:model-value="handleFilterChange(field
|
|
289
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
288
290
|
/>
|
|
289
291
|
|
|
290
292
|
<!-- Date Filter -->
|
|
291
293
|
<DFormInput
|
|
292
294
|
v-else-if="field.filter === 'date'"
|
|
293
|
-
:model-value="effectiveFilters[field
|
|
295
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
294
296
|
type="date"
|
|
295
297
|
size="sm"
|
|
296
|
-
@update:model-value="handleFilterChange(field
|
|
298
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
297
299
|
/>
|
|
298
300
|
|
|
299
301
|
<!-- No filter for this column -->
|
|
@@ -306,7 +308,7 @@
|
|
|
306
308
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
307
309
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
308
310
|
<div class="flex-grow-1">
|
|
309
|
-
<div class="fw-semibold">{{ label
|
|
311
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
310
312
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
311
313
|
</div>
|
|
312
314
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -316,23 +318,19 @@
|
|
|
316
318
|
</div>
|
|
317
319
|
</template>
|
|
318
320
|
|
|
319
|
-
<!--
|
|
321
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
322
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
323
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
324
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
320
325
|
<template
|
|
321
|
-
v-for="
|
|
326
|
+
v-for="name in forwardedTableSlotNames"
|
|
327
|
+
:key="name"
|
|
322
328
|
#[name]="slotProps"
|
|
323
329
|
>
|
|
324
330
|
<!--
|
|
325
|
-
@slot
|
|
326
|
-
@binding {object} item The row data object for this cell.
|
|
327
|
-
@binding {any} value The cell value for the field.
|
|
328
|
-
@binding {number} index The zero-based row index.
|
|
329
|
-
@binding {object} field The field definition for the column.
|
|
331
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
330
332
|
-->
|
|
331
|
-
<slot
|
|
332
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
333
|
-
:name="name"
|
|
334
|
-
v-bind="slotProps"
|
|
335
|
-
/>
|
|
333
|
+
<slot :name="name" v-bind="slotProps" />
|
|
336
334
|
</template>
|
|
337
335
|
</DTable>
|
|
338
336
|
|
|
@@ -433,15 +431,15 @@
|
|
|
433
431
|
</div>
|
|
434
432
|
|
|
435
433
|
<!-- Pagination and Controls (API mode) -->
|
|
436
|
-
<div v-if="isProviderMode &&
|
|
434
|
+
<div v-if="isProviderMode && providerPagination" class="mt-3">
|
|
437
435
|
<!-- Top row: Pagination and Per-page selector -->
|
|
438
436
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
439
437
|
<!-- Pagination controls (only when multiple pages) -->
|
|
440
438
|
<DPagination
|
|
441
|
-
v-if="showPagination &&
|
|
442
|
-
:model-value="
|
|
443
|
-
:total-rows="
|
|
444
|
-
:per-page="
|
|
439
|
+
v-if="showPagination && providerPagination.total > providerPagination.per_page"
|
|
440
|
+
:model-value="providerPagination.current_page"
|
|
441
|
+
:total-rows="providerPagination.total"
|
|
442
|
+
:per-page="providerPagination.per_page"
|
|
445
443
|
size="sm"
|
|
446
444
|
@update:model-value="handleApiPageChange"
|
|
447
445
|
/>
|
|
@@ -464,18 +462,18 @@
|
|
|
464
462
|
<!-- Bottom row: Info text -->
|
|
465
463
|
<div class="small text-muted">
|
|
466
464
|
<div>
|
|
467
|
-
<template v-if="
|
|
468
|
-
{{
|
|
465
|
+
<template v-if="providerPagination.total > providerPagination.per_page">
|
|
466
|
+
{{ providerPagination.from }} to {{ providerPagination.to }} out of {{ providerPagination.total }} {{ providerPagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
469
467
|
</template>
|
|
470
|
-
<template v-else-if="
|
|
471
|
-
{{
|
|
468
|
+
<template v-else-if="providerPagination.total === 1">
|
|
469
|
+
{{ providerPagination.total }} {{ singularItemName }}.
|
|
472
470
|
</template>
|
|
473
471
|
<template v-else>
|
|
474
|
-
{{
|
|
472
|
+
{{ providerPagination.total }} {{ pluralItemName }}.
|
|
475
473
|
</template>
|
|
476
474
|
</div>
|
|
477
|
-
<div v-if="hasActiveFilters &&
|
|
478
|
-
<small>Filtered from {{
|
|
475
|
+
<div v-if="hasActiveFilters && providerPagination.total_unfiltered">
|
|
476
|
+
<small>Filtered from {{ providerPagination.total_unfiltered }} {{ providerPagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
|
|
479
477
|
</div>
|
|
480
478
|
</div>
|
|
481
479
|
</div>
|
|
@@ -499,9 +497,18 @@
|
|
|
499
497
|
<span>Loading…</span>
|
|
500
498
|
</div>
|
|
501
499
|
|
|
502
|
-
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise)
|
|
500
|
+
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise).
|
|
501
|
+
|
|
502
|
+
Keyed per modal open: the form OBJECT is created once and then
|
|
503
|
+
reseeded in place for each row, so without a key Vue reuses the
|
|
504
|
+
same DXField instances across records — and any per-field UI
|
|
505
|
+
state rides along with them. That leaked a revealed password
|
|
506
|
+
from one row into the next. The key gives each record a fresh
|
|
507
|
+
field subtree; the data still lives in `editForm`, so nothing
|
|
508
|
+
is lost by remounting. -->
|
|
503
509
|
<DXForm
|
|
504
510
|
v-if="editForm"
|
|
511
|
+
:key="editFormInstanceKey"
|
|
505
512
|
v-model:active-tab="activeTabIndex"
|
|
506
513
|
:form="editForm"
|
|
507
514
|
:fields="editFields"
|
|
@@ -628,7 +635,7 @@
|
|
|
628
635
|
</template>
|
|
629
636
|
|
|
630
637
|
<script setup lang="ts" generic="T = any">
|
|
631
|
-
import { computed, ref, watch, useSlots } from "vue";
|
|
638
|
+
import { computed, getCurrentInstance, ref, watch, useSlots } from "vue";
|
|
632
639
|
import { router } from "@inertiajs/vue3";
|
|
633
640
|
import axios from "axios";
|
|
634
641
|
import pluralize from "pluralize";
|
|
@@ -657,6 +664,27 @@ export interface FilterOption {
|
|
|
657
664
|
export interface TableField {
|
|
658
665
|
key: string;
|
|
659
666
|
label?: string;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The key this column's filter is sent under, when it differs from the
|
|
670
|
+
* column's own key (default: `key`). Lets a *display* column filter on a
|
|
671
|
+
* different server param — e.g. a "Customer" column rendering a name but
|
|
672
|
+
* filtering on `customer_id` — instead of forcing the column to be named
|
|
673
|
+
* after the server's filter param and the human-facing value into a
|
|
674
|
+
* `#cell` slot.
|
|
675
|
+
*/
|
|
676
|
+
filterKey?: string;
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* For a `select` filter: adds an option meaning "has no value", e.g.
|
|
680
|
+
* `filterNullText: "Unassigned"` on an assignee column. Selecting it sends
|
|
681
|
+
* `filterNullValue` (default `"null"`) as the filter value; client-side, it
|
|
682
|
+
* matches rows whose value is null, undefined or empty.
|
|
683
|
+
*/
|
|
684
|
+
filterNullText?: string;
|
|
685
|
+
|
|
686
|
+
/** The value sent when the `filterNullText` option is chosen. Default `"null"`. */
|
|
687
|
+
filterNullValue?: string;
|
|
660
688
|
sortable?: boolean;
|
|
661
689
|
hint?: string;
|
|
662
690
|
filter?: FilterType;
|
|
@@ -842,6 +870,45 @@ export interface Props<TItem = any> {
|
|
|
842
870
|
/** API endpoint for creating new items (e.g., "/api/products") — enables "New" button */
|
|
843
871
|
createUrl?: string;
|
|
844
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Render the built-in "New {item}" button in the card header (default
|
|
875
|
+
* `true` whenever `createUrl` is set). Set `false` to drive the create
|
|
876
|
+
* modal from your own trigger elsewhere — e.g. a button in the dashboard
|
|
877
|
+
* navbar's actions slot calling the exposed `openCreate()`. With no
|
|
878
|
+
* `title` and no `header` slot, the header is then dropped entirely
|
|
879
|
+
* rather than left empty.
|
|
880
|
+
*/
|
|
881
|
+
showCreateButton?: boolean;
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Render a `<tfoot>` mirroring the header row, so `#foot(<key>)` slots can
|
|
885
|
+
* hold per-column totals that line up under their data (#99). Without it
|
|
886
|
+
* there is no footer to put them in. For a fully custom footer, use the
|
|
887
|
+
* `custom-foot` slot instead.
|
|
888
|
+
*/
|
|
889
|
+
footClone?: boolean;
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Show a message row when there are no rows (default `true`). An empty
|
|
893
|
+
* table body is indistinguishable from a broken one, so the message is on
|
|
894
|
+
* by default. Override the wording with `emptyText`, or the whole row with
|
|
895
|
+
* the `empty` slot.
|
|
896
|
+
*/
|
|
897
|
+
showEmpty?: boolean;
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Message shown when there are no rows. Defaults to "No {items} found", or
|
|
901
|
+
* "No {items} match your filters" when a column filter is active.
|
|
902
|
+
*/
|
|
903
|
+
emptyText?: string;
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Rows currently expanded, as a `v-model`. Pair with the `row-expansion`
|
|
907
|
+
* slot to render detail content under a row (#112) instead of forcing every
|
|
908
|
+
* per-row detail into a modal.
|
|
909
|
+
*/
|
|
910
|
+
expandedItems?: TItem[];
|
|
911
|
+
|
|
845
912
|
/** Enable client-side filtering, sorting, and pagination on items array */
|
|
846
913
|
clientSide?: boolean;
|
|
847
914
|
}
|
|
@@ -872,6 +939,9 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
872
939
|
columnSize: "12",
|
|
873
940
|
card: true,
|
|
874
941
|
editModalSize: "lg",
|
|
942
|
+
showCreateButton: true,
|
|
943
|
+
footClone: false,
|
|
944
|
+
showEmpty: true,
|
|
875
945
|
});
|
|
876
946
|
|
|
877
947
|
const emit = defineEmits<{
|
|
@@ -899,6 +969,8 @@ const emit = defineEmits<{
|
|
|
899
969
|
deleteError: [item: T, error: any];
|
|
900
970
|
/** `v-model:sortBy` update, emitted with the normalized single-column sort array when sorting changes. */
|
|
901
971
|
'update:sortBy': [sortBy: BTableSortBy[]];
|
|
972
|
+
/** `v-model:expandedItems` update, emitted with the rows currently expanded. */
|
|
973
|
+
'update:expandedItems': [items: T[]];
|
|
902
974
|
/** `v-model:filters` update, emitted with the new filter map when a column filter changes. */
|
|
903
975
|
'update:filters': [filters: Record<string, string>];
|
|
904
976
|
/** `v-model:perPage` update, emitted with the new page size when the per-page selector changes. */
|
|
@@ -921,17 +993,68 @@ if (props.clientSide && (props.apiUrl || props.inertiaUrl)) {
|
|
|
921
993
|
// Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
|
|
922
994
|
const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.loading);
|
|
923
995
|
|
|
924
|
-
|
|
925
|
-
|
|
996
|
+
/*
|
|
997
|
+
* `perPage`, `sortBy` and `filters` are dual-purpose: with a `v-model` they are
|
|
998
|
+
* CONTROLLED state; without one they read as an INITIAL value.
|
|
999
|
+
*
|
|
1000
|
+
* DXTable used to treat any passed value as controlled, so `:per-page="50"` —
|
|
1001
|
+
* the natural way to say "start with 50 per page" — rendered a working-looking
|
|
1002
|
+
* per-page selector that did nothing: the user picked 10, the select showed 10,
|
|
1003
|
+
* and the table kept rendering 50 (#110). The control responded, nothing
|
|
1004
|
+
* errored, and the table quietly ignored it.
|
|
1005
|
+
*
|
|
1006
|
+
* A prop is only controlled if the consumer is listening for its update, so
|
|
1007
|
+
* that is what we check. It has to come off the raw vnode props: Vue strips
|
|
1008
|
+
* DECLARED emit listeners out of `$attrs`, so `useAttrs()` can't see them.
|
|
1009
|
+
* Listener presence doesn't meaningfully change over a component's life, so
|
|
1010
|
+
* this is resolved once at setup rather than per render.
|
|
1011
|
+
*/
|
|
1012
|
+
const instance = getCurrentInstance();
|
|
1013
|
+
|
|
1014
|
+
const hasListener = (event: string): boolean => {
|
|
1015
|
+
const vnodeProps = instance?.vnode.props ?? {};
|
|
1016
|
+
const toHandlerKey = (name: string) => `on${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
1017
|
+
// `v-model:per-page` / `@row-clicked` compile to camelCase handler keys, but
|
|
1018
|
+
// a hand-written hyphenated listener stays hyphenated — accept either.
|
|
1019
|
+
const hyphenated = event.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
1020
|
+
return toHandlerKey(event) in vnodeProps || toHandlerKey(hyphenated) in vnodeProps;
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const isControlled = {
|
|
1024
|
+
perPage: hasListener('update:perPage'),
|
|
1025
|
+
sortBy: hasListener('update:sortBy'),
|
|
1026
|
+
filters: hasListener('update:filters'),
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
/*
|
|
1030
|
+
* Rows are interactive when clicking one does something: the built-in edit
|
|
1031
|
+
* modal (`editFields`) opens, or the consumer is listening for `row-clicked`.
|
|
1032
|
+
* The cursor and hover affordance follow from that — DXTable already knows,
|
|
1033
|
+
* so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
|
|
1034
|
+
* The listener case was the gap: only `editFields` used to count.
|
|
1035
|
+
*/
|
|
1036
|
+
const rowsAreInteractive = computed(
|
|
1037
|
+
() => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const rowCursor = computed(() => (rowsAreInteractive.value ? 'pointer' : 'default'));
|
|
1041
|
+
const rowHoverBackground = computed(() =>
|
|
1042
|
+
rowsAreInteractive.value ? 'var(--bs-table-hover-bg)' : 'inherit',
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
// Internal sortBy state, seeded from the prop when it's an initial value.
|
|
1046
|
+
const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
|
|
926
1047
|
|
|
927
|
-
|
|
928
|
-
|
|
1048
|
+
const effectiveSortBy = computed(() =>
|
|
1049
|
+
isControlled.sortBy && props.sortBy !== undefined ? props.sortBy : internalSortBy.value,
|
|
1050
|
+
);
|
|
929
1051
|
|
|
930
|
-
// Internal filters state
|
|
931
|
-
const internalFilters = ref<Record<string, string>>({});
|
|
1052
|
+
// Internal filters state, seeded from the prop when it's an initial value.
|
|
1053
|
+
const internalFilters = ref<Record<string, string>>({ ...(props.filters ?? {}) });
|
|
932
1054
|
|
|
933
|
-
|
|
934
|
-
|
|
1055
|
+
const effectiveFilters = computed(() =>
|
|
1056
|
+
isControlled.filters && props.filters !== undefined ? props.filters : internalFilters.value,
|
|
1057
|
+
);
|
|
935
1058
|
|
|
936
1059
|
// Computed: check if any field has filtering enabled
|
|
937
1060
|
const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
|
|
@@ -945,6 +1068,52 @@ const hasActiveFilters = computed(() => {
|
|
|
945
1068
|
// API mode pagination metadata (extracted from responses)
|
|
946
1069
|
const apiPaginationMeta = ref<PaginationData | null>(null);
|
|
947
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* Pagination shown in provider mode.
|
|
1073
|
+
*
|
|
1074
|
+
* Only the built-in `apiUrl` provider can populate `apiPaginationMeta` — it's
|
|
1075
|
+
* the one that knows to read `response.data.pagination`. A CUSTOM provider
|
|
1076
|
+
* returns rows and nothing else, so the pager simply never rendered: a table
|
|
1077
|
+
* with no pagination and no warning, which is why a page needing custom request
|
|
1078
|
+
* shaping quietly lost its pager (#106). A custom provider now takes its
|
|
1079
|
+
* pagination from the `pagination` prop, and says so when it's missing.
|
|
1080
|
+
*/
|
|
1081
|
+
// `pagination` carries a default from withDefaults, so props alone can't tell
|
|
1082
|
+
// whether the consumer actually passed one — ask the vnode. (Gating on
|
|
1083
|
+
// `total > 0` instead would treat a legitimately empty page as "no pagination".)
|
|
1084
|
+
const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
|
|
1085
|
+
|
|
1086
|
+
const providerPagination = computed<PaginationData | null>(() => {
|
|
1087
|
+
if (apiPaginationMeta.value) return apiPaginationMeta.value;
|
|
1088
|
+
if (props.provider && paginationWasProvided && props.pagination) {
|
|
1089
|
+
return props.pagination;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
/*
|
|
1095
|
+
* Loud, once, rather than a silently pager-less table — but only AFTER the
|
|
1096
|
+
* provider has actually run, and judged on `providerPagination` (not the raw
|
|
1097
|
+
* apiPaginationMeta). Warning at setup would fire on every consumer whose
|
|
1098
|
+
* `pagination` prop arrives with the response rather than before it.
|
|
1099
|
+
*/
|
|
1100
|
+
const providerHasRun = ref(false);
|
|
1101
|
+
let warnedAboutProviderPagination = false;
|
|
1102
|
+
watch(
|
|
1103
|
+
() => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
|
|
1104
|
+
([provider, showPagination, pagination, hasRun]) => {
|
|
1105
|
+
if (warnedAboutProviderPagination || !hasRun) return;
|
|
1106
|
+
if (provider && showPagination && !pagination) {
|
|
1107
|
+
warnedAboutProviderPagination = true;
|
|
1108
|
+
// eslint-disable-next-line no-console
|
|
1109
|
+
console.warn(
|
|
1110
|
+
'[DXTable] A custom `provider` cannot report its own pagination, so no pager will render. ' +
|
|
1111
|
+
'Pass the page metadata via the `pagination` prop, or set `:show-pagination="false"` to hide the pager deliberately.',
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
);
|
|
1116
|
+
|
|
948
1117
|
// ============================================
|
|
949
1118
|
// Client-Side Mode: Filtering, Sorting, Pagination
|
|
950
1119
|
// ============================================
|
|
@@ -966,8 +1135,23 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
966
1135
|
return props.items.filter(item => {
|
|
967
1136
|
return filterKeys.every(key => {
|
|
968
1137
|
const filterValue = filters[key].trim().toLowerCase();
|
|
969
|
-
|
|
970
|
-
const
|
|
1138
|
+
// Resolve by the FILTER key, which may differ from the column's key.
|
|
1139
|
+
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
1140
|
+
// `filterKey` names the param the SERVER filters on; a local row may
|
|
1141
|
+
// not carry it at all (a `customer_name` row filtered on
|
|
1142
|
+
// `customer_id`). Match on the filter key when the row has it,
|
|
1143
|
+
// otherwise fall back to the column's own value — which is also the
|
|
1144
|
+
// one the user can actually see and type against.
|
|
1145
|
+
const itemValue =
|
|
1146
|
+
field && key in (item as any)
|
|
1147
|
+
? (item as any)[key]
|
|
1148
|
+
: (item as any)[field?.key ?? key];
|
|
1149
|
+
|
|
1150
|
+
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
1151
|
+
// MATCHES an absent value rather than failing on it.
|
|
1152
|
+
if (field?.filterNullText && filters[key] === filterNullValueFor(field)) {
|
|
1153
|
+
return itemValue === null || itemValue === undefined || itemValue === '';
|
|
1154
|
+
}
|
|
971
1155
|
|
|
972
1156
|
if (itemValue === null || itemValue === undefined) {
|
|
973
1157
|
return false;
|
|
@@ -1037,21 +1221,43 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1037
1221
|
const apiFilterValues = ref<Record<string, string[]>>({});
|
|
1038
1222
|
|
|
1039
1223
|
// Computed: Get effective filter options for a field
|
|
1224
|
+
// The key a column's filter is SENT under, which is not always the column's own
|
|
1225
|
+
// key: a display column can filter on a different server param via `filterKey`.
|
|
1226
|
+
const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key;
|
|
1227
|
+
|
|
1228
|
+
// The value that means "has no value" for a select filter (#106).
|
|
1229
|
+
const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Column header text. An explicitly empty `label` means an EMPTY header — an
|
|
1233
|
+
* actions column headed `actions` (lowercase, as-keyed) is a leak, not a
|
|
1234
|
+
* default. Only fall back when no label was declared at all.
|
|
1235
|
+
*/
|
|
1236
|
+
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1237
|
+
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1238
|
+
|
|
1040
1239
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1240
|
+
const options: FilterOption[] = [];
|
|
1241
|
+
|
|
1242
|
+
// An "Unassigned"-style option, so a select filter can express "no value".
|
|
1243
|
+
if (field.filterNullText) {
|
|
1244
|
+
options.push({ value: filterNullValueFor(field), text: field.filterNullText });
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1041
1247
|
// If field has static filterOptions, use those
|
|
1042
1248
|
if (field.filterOptions && field.filterOptions.length > 0) {
|
|
1043
|
-
return field.filterOptions;
|
|
1249
|
+
return [...options, ...field.filterOptions];
|
|
1044
1250
|
}
|
|
1045
1251
|
|
|
1046
|
-
// Otherwise, check for server-provided values
|
|
1047
|
-
const serverValues =
|
|
1252
|
+
// Otherwise, check for server-provided values (keyed by the filter key)
|
|
1253
|
+
const serverValues =
|
|
1254
|
+
props.filterValues?.[filterKeyFor(field)] ?? apiFilterValues.value[filterKeyFor(field)];
|
|
1048
1255
|
|
|
1049
1256
|
if (serverValues && serverValues.length > 0) {
|
|
1050
|
-
|
|
1051
|
-
return serverValues.map(value => ({ value, text: value }));
|
|
1257
|
+
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1052
1258
|
}
|
|
1053
1259
|
|
|
1054
|
-
return
|
|
1260
|
+
return options;
|
|
1055
1261
|
};
|
|
1056
1262
|
|
|
1057
1263
|
// LocalStorage key for perPage preference
|
|
@@ -1100,8 +1306,10 @@ watch(() => props.pagination?.per_page, (newPerPage) => {
|
|
|
1100
1306
|
|
|
1101
1307
|
// Watch for external filter changes (when filters prop is controlled by parent)
|
|
1102
1308
|
watch(() => props.filters, (newFilters, oldFilters) => {
|
|
1103
|
-
// Only
|
|
1104
|
-
|
|
1309
|
+
// Only when the consumer CONTROLS filters. Uncontrolled, the prop is just a
|
|
1310
|
+
// seed: `effectiveFilters` stays internal, so refreshing on a later prop
|
|
1311
|
+
// change would fetch with filters the table isn't actually showing (#110).
|
|
1312
|
+
if (!isControlled.filters || props.filters === undefined) return;
|
|
1105
1313
|
|
|
1106
1314
|
// Only refresh if filters actually changed
|
|
1107
1315
|
if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
|
|
@@ -1111,15 +1319,13 @@ watch(() => props.filters, (newFilters, oldFilters) => {
|
|
|
1111
1319
|
refresh();
|
|
1112
1320
|
} else if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1113
1321
|
// Inertia mode - trigger navigation with new filters
|
|
1114
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1115
1322
|
router.get(
|
|
1116
1323
|
props.inertiaUrl!,
|
|
1117
1324
|
{
|
|
1118
1325
|
page: 1, // Reset to first page when filters change
|
|
1119
|
-
sortBy: currentSort.key,
|
|
1120
|
-
sortOrder: currentSort.order,
|
|
1121
1326
|
filters: newFilters,
|
|
1122
1327
|
perPage: effectivePerPage.value,
|
|
1328
|
+
...sortParams(effectiveSortBy.value),
|
|
1123
1329
|
},
|
|
1124
1330
|
{ preserveState: true }
|
|
1125
1331
|
);
|
|
@@ -1152,8 +1358,9 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
|
|
|
1152
1358
|
|
|
1153
1359
|
// Computed effective perPage (use external if provided, otherwise internal)
|
|
1154
1360
|
const effectivePerPage = computed(() => {
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1361
|
+
// Only when the consumer is actually driving it (v-model). A bare
|
|
1362
|
+
// `:per-page="50"` is an initial value — see `isControlled` (#110).
|
|
1363
|
+
if (isControlled.perPage && props.perPage !== undefined) {
|
|
1157
1364
|
return props.perPage;
|
|
1158
1365
|
}
|
|
1159
1366
|
|
|
@@ -1163,7 +1370,7 @@ const effectivePerPage = computed(() => {
|
|
|
1163
1370
|
}
|
|
1164
1371
|
|
|
1165
1372
|
// For API mode, use internal state (which gets updated immediately on change)
|
|
1166
|
-
// Don't use
|
|
1373
|
+
// Don't use providerPagination.per_page here because it's from the previous request
|
|
1167
1374
|
// and causes the select to flicker when user changes it
|
|
1168
1375
|
return internalPerPage.value;
|
|
1169
1376
|
});
|
|
@@ -1238,7 +1445,10 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1238
1445
|
? clientSidePagination.value?.total || 0
|
|
1239
1446
|
: isInertiaMode.value
|
|
1240
1447
|
? props.pagination?.total || 0
|
|
1241
|
-
|
|
1448
|
+
// Same source as the pager itself — reading apiPaginationMeta here
|
|
1449
|
+
// hid the selector for a custom provider whose pagination comes
|
|
1450
|
+
// from the `pagination` prop, while the page buttons still showed.
|
|
1451
|
+
: providerPagination.value?.total || 0;
|
|
1242
1452
|
|
|
1243
1453
|
return total >= smallestOption;
|
|
1244
1454
|
});
|
|
@@ -1247,79 +1457,114 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1247
1457
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1248
1458
|
return props.fields
|
|
1249
1459
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
1250
|
-
.map(field => field
|
|
1460
|
+
.map(field => filterKeyFor(field));
|
|
1251
1461
|
});
|
|
1252
1462
|
|
|
1253
1463
|
// Error state for API mode
|
|
1254
1464
|
const apiError = ref<string | null>(null);
|
|
1255
1465
|
|
|
1256
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* The active sort, or `null` when there is none.
|
|
1468
|
+
*
|
|
1469
|
+
* The header sort cycles asc → desc → *unsorted*, and in that third state
|
|
1470
|
+
* BTable reports the column with no `order`. There is deliberately no fallback
|
|
1471
|
+
* column here: sorting by a magic `created_at` when the user has asked for no
|
|
1472
|
+
* sort means requesting a column the consumer never declared, which a strict
|
|
1473
|
+
* endpoint (e.g. spatie's QueryBuilder, where an undeclared sort is a 400)
|
|
1474
|
+
* rejects outright. No sort selected means no sort sent — the server applies
|
|
1475
|
+
* its own default ordering. Pass `sortBy` to give the table an initial sort.
|
|
1476
|
+
*/
|
|
1477
|
+
const activeSort = (sortBy: readonly BTableSortBy[] | undefined) => {
|
|
1478
|
+
const sort = sortBy?.[0];
|
|
1479
|
+
if (!sort?.key || !sort.order) return null;
|
|
1480
|
+
return { key: sort.key, order: sort.order };
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
// Sort params for a request — an empty object when nothing is sorted.
|
|
1484
|
+
const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
|
|
1485
|
+
const sort = activeSort(sortBy);
|
|
1486
|
+
return sort ? { sortBy: sort.key, sortOrder: sort.order } : {};
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
// Internal provider function when apiUrl is provided. It deliberately does NOT
|
|
1490
|
+
// catch: `effectiveProvider` wraps every provider (including a consumer's own)
|
|
1491
|
+
// in one error handler, so no provider can fail silently.
|
|
1257
1492
|
const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1258
1493
|
if (!props.apiUrl) return [];
|
|
1259
1494
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
: { key: 'created_at', order: 'desc' };
|
|
1267
|
-
|
|
1268
|
-
// Build request parameters
|
|
1269
|
-
const params: any = {
|
|
1270
|
-
page: context.currentPage,
|
|
1271
|
-
perPage: effectivePerPage.value,
|
|
1272
|
-
sortBy: sort.key,
|
|
1273
|
-
sortOrder: sort.order || 'desc',
|
|
1274
|
-
filters: effectiveFilters.value,
|
|
1275
|
-
};
|
|
1276
|
-
|
|
1277
|
-
// Request filter values on initial load
|
|
1278
|
-
if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
|
|
1279
|
-
params.filterValues = fieldsNeedingFilterValues.value;
|
|
1280
|
-
}
|
|
1495
|
+
const params: any = {
|
|
1496
|
+
page: context.currentPage,
|
|
1497
|
+
perPage: effectivePerPage.value,
|
|
1498
|
+
filters: effectiveFilters.value,
|
|
1499
|
+
...sortParams(context.sortBy),
|
|
1500
|
+
};
|
|
1281
1501
|
|
|
1282
|
-
|
|
1502
|
+
// Request filter values on initial load
|
|
1503
|
+
if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
|
|
1504
|
+
params.filterValues = fieldsNeedingFilterValues.value;
|
|
1505
|
+
}
|
|
1283
1506
|
|
|
1284
|
-
|
|
1285
|
-
if (response.data.pagination) {
|
|
1286
|
-
apiPaginationMeta.value = response.data.pagination;
|
|
1287
|
-
}
|
|
1507
|
+
const response = await axios.get(props.apiUrl, { params });
|
|
1288
1508
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1509
|
+
// Extract and store pagination metadata for display
|
|
1510
|
+
if (response.data.pagination) {
|
|
1511
|
+
apiPaginationMeta.value = response.data.pagination;
|
|
1512
|
+
}
|
|
1293
1513
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1514
|
+
// Extract and store filter values
|
|
1515
|
+
if (response.data.filterValues) {
|
|
1516
|
+
apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
|
|
1517
|
+
}
|
|
1297
1518
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|| error?.message
|
|
1301
|
-
|| 'Failed to load data. Please try again.';
|
|
1302
|
-
apiError.value = errorMessage;
|
|
1519
|
+
return response.data.data;
|
|
1520
|
+
};
|
|
1303
1521
|
|
|
1522
|
+
/**
|
|
1523
|
+
* Wraps whichever provider is in play — the built-in `apiUrl` one or a
|
|
1524
|
+
* consumer's own `provider` — so a rejected fetch surfaces an error instead of
|
|
1525
|
+
* rendering as zero rows. A failed query and a genuinely empty result look
|
|
1526
|
+
* identical otherwise, which is how a broken table passes review and then shows
|
|
1527
|
+
* a blank page to a user. A consumer's provider used to be the silent case: the
|
|
1528
|
+
* error handling lived inside the internal one, so it never covered them.
|
|
1529
|
+
*/
|
|
1530
|
+
const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1531
|
+
const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
|
|
1532
|
+
if (!provider) return [];
|
|
1533
|
+
|
|
1534
|
+
apiError.value = null;
|
|
1535
|
+
try {
|
|
1536
|
+
return await provider(context);
|
|
1537
|
+
} catch (error: any) {
|
|
1538
|
+
// eslint-disable-next-line no-console
|
|
1539
|
+
console.error('DXTable: the data provider failed', error);
|
|
1540
|
+
apiError.value =
|
|
1541
|
+
error?.response?.data?.message ||
|
|
1542
|
+
error?.message ||
|
|
1543
|
+
'Failed to load data. Please try again.';
|
|
1304
1544
|
return [];
|
|
1545
|
+
} finally {
|
|
1546
|
+
providerHasRun.value = true;
|
|
1305
1547
|
}
|
|
1306
1548
|
};
|
|
1307
1549
|
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1550
|
+
// `wrappedProvider` is resolved fresh on each call, so this computed's IDENTITY
|
|
1551
|
+
// must not change when `apiUrl` does — BTable refetches when the provider
|
|
1552
|
+
// function changes, which would double every request that the explicit apiUrl
|
|
1553
|
+
// watcher already refetches (#82).
|
|
1554
|
+
const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
|
|
1555
|
+
props.provider || props.apiUrl ? wrappedProvider : undefined,
|
|
1556
|
+
);
|
|
1310
1557
|
|
|
1311
1558
|
const handlePageChange = (page: number) => {
|
|
1312
1559
|
// If inertiaUrl provided, handle navigation automatically
|
|
1313
1560
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1314
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1315
1561
|
router.get(
|
|
1316
1562
|
props.inertiaUrl!,
|
|
1317
1563
|
{
|
|
1318
1564
|
page,
|
|
1319
|
-
sortBy: currentSort.key,
|
|
1320
|
-
sortOrder: currentSort.order,
|
|
1321
1565
|
filters: effectiveFilters.value,
|
|
1322
1566
|
perPage: effectivePerPage.value,
|
|
1567
|
+
...sortParams(effectiveSortBy.value),
|
|
1323
1568
|
},
|
|
1324
1569
|
{ preserveState: true }
|
|
1325
1570
|
);
|
|
@@ -1353,8 +1598,8 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
|
1353
1598
|
}
|
|
1354
1599
|
}
|
|
1355
1600
|
|
|
1356
|
-
//
|
|
1357
|
-
if (
|
|
1601
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1602
|
+
if (!isControlled.sortBy) {
|
|
1358
1603
|
internalSortBy.value = normalizedSortBy;
|
|
1359
1604
|
}
|
|
1360
1605
|
|
|
@@ -1363,28 +1608,26 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
|
1363
1608
|
|
|
1364
1609
|
// Handle Inertia navigation automatically if URL provided
|
|
1365
1610
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
router.get(props.inertiaUrl!, params, { preserveState: true });
|
|
1611
|
+
// The cleared state has a key but no order — `sortParams` sends nothing
|
|
1612
|
+
// for it, rather than inventing an ascending sort the user didn't ask for.
|
|
1613
|
+
router.get(
|
|
1614
|
+
props.inertiaUrl!,
|
|
1615
|
+
{
|
|
1616
|
+
page: props.pagination?.current_page || 1,
|
|
1617
|
+
filters: effectiveFilters.value,
|
|
1618
|
+
perPage: effectivePerPage.value,
|
|
1619
|
+
...sortParams(normalizedSortBy),
|
|
1620
|
+
},
|
|
1621
|
+
{ preserveState: true },
|
|
1622
|
+
);
|
|
1380
1623
|
}
|
|
1381
1624
|
|
|
1382
|
-
// Emit simplified sortChange event for backward compatibility
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1625
|
+
// Emit simplified sortChange event for backward compatibility. Its payload
|
|
1626
|
+
// can only express an ACTIVE sort, so the cleared state emits nothing here —
|
|
1627
|
+
// listen to `update:sortBy` (which carries the empty array) to observe it.
|
|
1628
|
+
const sort = activeSort(normalizedSortBy);
|
|
1629
|
+
if (isInertiaMode.value && sort) {
|
|
1630
|
+
emit('sortChange', sort);
|
|
1388
1631
|
}
|
|
1389
1632
|
};
|
|
1390
1633
|
|
|
@@ -1404,8 +1647,8 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1404
1647
|
}
|
|
1405
1648
|
const perPageNum = typeof rawPerPage === 'string' ? parseInt(rawPerPage, 10) : rawPerPage;
|
|
1406
1649
|
|
|
1407
|
-
//
|
|
1408
|
-
if (
|
|
1650
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1651
|
+
if (!isControlled.perPage) {
|
|
1409
1652
|
internalPerPage.value = perPageNum;
|
|
1410
1653
|
}
|
|
1411
1654
|
|
|
@@ -1423,15 +1666,13 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1423
1666
|
|
|
1424
1667
|
// Handle navigation automatically
|
|
1425
1668
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1426
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1427
1669
|
router.get(
|
|
1428
1670
|
props.inertiaUrl!,
|
|
1429
1671
|
{
|
|
1430
1672
|
page: 1, // Reset to first page when changing perPage
|
|
1431
|
-
sortBy: currentSort.key,
|
|
1432
|
-
sortOrder: currentSort.order,
|
|
1433
1673
|
filters: effectiveFilters.value,
|
|
1434
1674
|
perPage: perPageNum,
|
|
1675
|
+
...sortParams(effectiveSortBy.value),
|
|
1435
1676
|
},
|
|
1436
1677
|
{ preserveState: true }
|
|
1437
1678
|
);
|
|
@@ -1455,8 +1696,8 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1455
1696
|
delete newFilters[fieldKey];
|
|
1456
1697
|
}
|
|
1457
1698
|
|
|
1458
|
-
//
|
|
1459
|
-
if (
|
|
1699
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1700
|
+
if (!isControlled.filters) {
|
|
1460
1701
|
internalFilters.value = newFilters;
|
|
1461
1702
|
}
|
|
1462
1703
|
|
|
@@ -1478,14 +1719,12 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1478
1719
|
filterDebounceTimer = setTimeout(() => {
|
|
1479
1720
|
// Handle Inertia navigation automatically if URL provided
|
|
1480
1721
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1481
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1482
1722
|
router.get(
|
|
1483
1723
|
props.inertiaUrl!,
|
|
1484
1724
|
{
|
|
1485
1725
|
page: 1, // Reset to first page when filtering
|
|
1486
|
-
sortBy: currentSort.key,
|
|
1487
|
-
sortOrder: currentSort.order,
|
|
1488
1726
|
filters: newFilters,
|
|
1727
|
+
...sortParams(effectiveSortBy.value),
|
|
1489
1728
|
},
|
|
1490
1729
|
{ preserveState: true }
|
|
1491
1730
|
);
|
|
@@ -1519,6 +1758,12 @@ const refresh = () => {
|
|
|
1519
1758
|
|
|
1520
1759
|
// Edit Modal State
|
|
1521
1760
|
const showEditModal = ref(false);
|
|
1761
|
+
|
|
1762
|
+
// Bumped on every modal open (edit or create) to key the DXForm subtree, so
|
|
1763
|
+
// each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
|
|
1764
|
+
// which reseeds the SAME record — remounting there would discard edits the
|
|
1765
|
+
// user has already made while the fetch was in flight.
|
|
1766
|
+
const editFormInstanceKey = ref(0);
|
|
1522
1767
|
const selectedItem = ref<T | null>(null);
|
|
1523
1768
|
const editForm = ref<any>(null);
|
|
1524
1769
|
const activeTabIndex = ref(0);
|
|
@@ -1553,7 +1798,74 @@ try {
|
|
|
1553
1798
|
// Forward only the keyed edit slots the consumer actually provided, so
|
|
1554
1799
|
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1555
1800
|
// a real custom-value override.
|
|
1801
|
+
// Fields whose value actually belongs in the payload. A presentational field
|
|
1802
|
+
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
1803
|
+
const submittableEditFields = computed(() =>
|
|
1804
|
+
(props.editFields ?? []).filter((field) => field.submit !== false),
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
1808
|
+
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
1809
|
+
// its key back into the form data after seeding. Strip them on the way out.
|
|
1810
|
+
const nonSubmittedFieldKeys = computed(
|
|
1811
|
+
() => new Set((props.editFields ?? []).filter((field) => field.submit === false).map((field) => field.key)),
|
|
1812
|
+
);
|
|
1813
|
+
|
|
1814
|
+
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
1815
|
+
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
1816
|
+
return Object.fromEntries(
|
|
1817
|
+
Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
|
|
1818
|
+
);
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1556
1821
|
const tableSlots = useSlots();
|
|
1822
|
+
|
|
1823
|
+
/*
|
|
1824
|
+
* Slot forwarding to the inner table.
|
|
1825
|
+
*
|
|
1826
|
+
* DXTable used to forward only slots whose name started with `cell`, which is
|
|
1827
|
+
* why it had no footer/totals row (#99), no empty state (#111) and no
|
|
1828
|
+
* expandable rows (#112): BTable supports all three, but the slots never
|
|
1829
|
+
* reached it. Anything BTable understands is now forwarded, with two
|
|
1830
|
+
* deliberate exceptions that DXTable renders itself:
|
|
1831
|
+
*
|
|
1832
|
+
* - `head(<key>)` — DXTable draws its own column headers (sort indicators,
|
|
1833
|
+
* field hints). Forwarding a consumer's would silently drop those.
|
|
1834
|
+
* - `thead-top` — DXTable renders the inline filter row there.
|
|
1835
|
+
*/
|
|
1836
|
+
const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
|
|
1837
|
+
const TABLE_SLOT_NAMES = new Set([
|
|
1838
|
+
'custom-foot',
|
|
1839
|
+
'empty',
|
|
1840
|
+
'empty-filtered',
|
|
1841
|
+
'row-expansion',
|
|
1842
|
+
'table-busy',
|
|
1843
|
+
'table-caption',
|
|
1844
|
+
'table-colgroup',
|
|
1845
|
+
'thead-sub',
|
|
1846
|
+
'top-row',
|
|
1847
|
+
'bottom-row',
|
|
1848
|
+
]);
|
|
1849
|
+
|
|
1850
|
+
const isTableSlot = (name: string) =>
|
|
1851
|
+
TABLE_SLOT_NAMES.has(name) || TABLE_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
1852
|
+
|
|
1853
|
+
const forwardedTableSlotNames = computed(() =>
|
|
1854
|
+
Object.keys(tableSlots).filter((name) => isTableSlot(name)),
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
// Table-level features of the inner BTable, exposed verbatim and bound to all
|
|
1858
|
+
// three data modes from one place.
|
|
1859
|
+
const tablePassthroughProps = computed(() => ({
|
|
1860
|
+
footClone: props.footClone,
|
|
1861
|
+
showEmpty: props.showEmpty,
|
|
1862
|
+
emptyText:
|
|
1863
|
+
props.emptyText ??
|
|
1864
|
+
(hasActiveFilters.value
|
|
1865
|
+
? `No ${pluralItemName.value} match your filters`
|
|
1866
|
+
: `No ${pluralItemName.value} found`),
|
|
1867
|
+
expandedItems: props.expandedItems,
|
|
1868
|
+
}));
|
|
1557
1869
|
const editFieldKeys = computed<string[]>(() =>
|
|
1558
1870
|
(props.editFields ?? []).map((field: any) => field.key),
|
|
1559
1871
|
);
|
|
@@ -1580,6 +1892,11 @@ const tabAfterSlotKeys = computed(() =>
|
|
|
1580
1892
|
const singularItemName = computed(() => props.itemName);
|
|
1581
1893
|
const pluralItemName = computed(() => pluralize(props.itemName));
|
|
1582
1894
|
|
|
1895
|
+
// The built-in "New {item}" button needs both a create endpoint and consent.
|
|
1896
|
+
// Gating the header's own v-if on this (not on `createUrl`) means a table that
|
|
1897
|
+
// only had a header to host the button loses the empty header too.
|
|
1898
|
+
const showsCreateButton = computed(() => Boolean(props.createUrl) && props.showCreateButton);
|
|
1899
|
+
|
|
1583
1900
|
// Computed: Modal title (supports function)
|
|
1584
1901
|
const computedModalTitle = computed(() => {
|
|
1585
1902
|
if (isCreateMode.value) {
|
|
@@ -1620,19 +1937,22 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1620
1937
|
// seeding is synchronous — no interleaving between successive row opens.
|
|
1621
1938
|
if (!editForm.value) {
|
|
1622
1939
|
const formData: Record<string, any> = {};
|
|
1623
|
-
|
|
1940
|
+
// Presentational fields (submit: false) lay the form out; they hold
|
|
1941
|
+
// no data and must not be POSTed just because they were declared.
|
|
1942
|
+
submittableEditFields.value.forEach(field => {
|
|
1624
1943
|
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1625
1944
|
});
|
|
1626
1945
|
editForm.value = useForm(formData);
|
|
1627
1946
|
} else {
|
|
1628
1947
|
// Update existing form
|
|
1629
|
-
|
|
1948
|
+
submittableEditFields.value.forEach(field => {
|
|
1630
1949
|
editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1631
1950
|
});
|
|
1632
1951
|
editForm.value.clearErrors();
|
|
1633
1952
|
}
|
|
1634
1953
|
|
|
1635
1954
|
// Open modal
|
|
1955
|
+
editFormInstanceKey.value++;
|
|
1636
1956
|
showEditModal.value = true;
|
|
1637
1957
|
|
|
1638
1958
|
// Optionally replace the row-seeded data with the full record.
|
|
@@ -1656,7 +1976,7 @@ const fetchFullRecordForEdit = async (item: T) => {
|
|
|
1656
1976
|
|
|
1657
1977
|
const record = response.data?.data ?? response.data;
|
|
1658
1978
|
if (record && editForm.value) {
|
|
1659
|
-
|
|
1979
|
+
submittableEditFields.value.forEach(field => {
|
|
1660
1980
|
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
1661
1981
|
editForm.value.data[field.key] = record[field.key];
|
|
1662
1982
|
}
|
|
@@ -1696,17 +2016,18 @@ const handleCreateNew = () => {
|
|
|
1696
2016
|
|
|
1697
2017
|
if (!editForm.value) {
|
|
1698
2018
|
const formData: Record<string, any> = {};
|
|
1699
|
-
|
|
2019
|
+
submittableEditFields.value.forEach(field => {
|
|
1700
2020
|
formData[field.key] = field.default ?? '';
|
|
1701
2021
|
});
|
|
1702
2022
|
editForm.value = useForm(formData);
|
|
1703
2023
|
} else {
|
|
1704
2024
|
// Reset existing form to defaults
|
|
1705
|
-
|
|
2025
|
+
submittableEditFields.value.forEach(field => {
|
|
1706
2026
|
editForm.value.data[field.key] = field.default ?? '';
|
|
1707
2027
|
});
|
|
1708
2028
|
editForm.value.clearErrors();
|
|
1709
2029
|
}
|
|
2030
|
+
editFormInstanceKey.value++;
|
|
1710
2031
|
showEditModal.value = true;
|
|
1711
2032
|
};
|
|
1712
2033
|
|
|
@@ -1727,6 +2048,7 @@ const performSave = async () => {
|
|
|
1727
2048
|
if (isCreateMode.value && props.createUrl) {
|
|
1728
2049
|
try {
|
|
1729
2050
|
await editForm.value.post(props.createUrl, {
|
|
2051
|
+
transform: stripNonSubmittedFields,
|
|
1730
2052
|
onSuccess: (data: any) => {
|
|
1731
2053
|
createToast?.({
|
|
1732
2054
|
title: 'Success',
|
|
@@ -1779,6 +2101,7 @@ const performSave = async () => {
|
|
|
1779
2101
|
const url = props.editUrl.replace(':id', itemId);
|
|
1780
2102
|
|
|
1781
2103
|
await editForm.value.put(url, {
|
|
2104
|
+
transform: stripNonSubmittedFields,
|
|
1782
2105
|
onSuccess: (data: any) => {
|
|
1783
2106
|
// Show success toast
|
|
1784
2107
|
createToast?.({
|
|
@@ -1926,13 +2249,13 @@ defineExpose({
|
|
|
1926
2249
|
</script>
|
|
1927
2250
|
|
|
1928
2251
|
<style scoped>
|
|
1929
|
-
/*
|
|
2252
|
+
/* Rows that do something on click look like it — see `rowsAreInteractive`. */
|
|
1930
2253
|
:deep(tbody tr) {
|
|
1931
|
-
cursor: v-bind(
|
|
2254
|
+
cursor: v-bind(rowCursor);
|
|
1932
2255
|
}
|
|
1933
2256
|
|
|
1934
2257
|
:deep(tbody tr:hover) {
|
|
1935
|
-
background-color: v-bind(
|
|
2258
|
+
background-color: v-bind(rowHoverBackground);
|
|
1936
2259
|
}
|
|
1937
2260
|
|
|
1938
2261
|
/* Improve pagination button sizing to match form controls */
|