@omnitend/dashboard-for-laravel 0.26.0 → 0.28.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/charts/chartTheme.d.ts +1 -0
- package/dist/components/extended/DXTable.vue.d.ts +9 -44
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +80 -0
- package/dist/components/extended/DXTablePagination.vue.d.ts +27 -0
- package/dist/composables/useResourceEditor.d.ts +74 -0
- package/dist/dashboard-for-laravel.js +12343 -12491
- 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/style.css +2 -2
- package/docs/public/api-reference.json +193 -7
- package/docs/public/docs-map.md +2 -2
- package/package.json +2 -2
- package/resources/css/theme.scss +168 -7
- package/resources/js/components/charts/DXBarChart.vue +1 -1
- package/resources/js/components/charts/DXDoughnutChart.vue +1 -1
- package/resources/js/components/charts/DXLineChart.vue +1 -1
- package/resources/js/components/charts/chartTheme.ts +19 -16
- package/resources/js/components/extended/DXTable.vue +243 -1008
- package/resources/js/components/extended/DXTableEditorModal.vue +247 -0
- package/resources/js/components/extended/DXTablePagination.vue +126 -0
- package/resources/js/composables/defineForm.ts +14 -2
- package/resources/js/composables/useResourceEditor.ts +542 -0
|
@@ -40,24 +40,25 @@
|
|
|
40
40
|
<p class="mt-2">{{ loadingText }}</p>
|
|
41
41
|
</div>
|
|
42
42
|
|
|
43
|
-
<!--
|
|
43
|
+
<!-- One table for all three data modes. The modes differ only
|
|
44
|
+
in a few bound props, supplied by `tableModeBindings`, so
|
|
45
|
+
the filter row, headers, dotted-cell rendering and slot
|
|
46
|
+
forwarding live ONCE here instead of being copied per mode
|
|
47
|
+
(#123). The `v-else-if` guard keeps "render nothing when
|
|
48
|
+
there is no data source" — none of the three modes true. -->
|
|
44
49
|
<DTable
|
|
45
|
-
v-else-if="isProviderMode"
|
|
50
|
+
v-else-if="isProviderMode || isClientSideMode || isInertiaMode"
|
|
46
51
|
ref="tableRef"
|
|
47
52
|
:key="tableSlotSignature($slots)"
|
|
48
|
-
:provider="effectiveProvider"
|
|
49
53
|
:fields="fields"
|
|
50
54
|
:sort-by="effectiveSortBy"
|
|
51
|
-
:per-page="effectivePerPage"
|
|
52
|
-
:current-page="apiCurrentPage"
|
|
53
55
|
:multisort="false"
|
|
54
56
|
:no-sortable-icon="true"
|
|
55
57
|
:striped="striped"
|
|
56
58
|
:hover="hover"
|
|
57
59
|
:responsive="responsive"
|
|
58
|
-
:busy="busy"
|
|
59
60
|
:tbody-tr-class="composeRowClass"
|
|
60
|
-
v-bind="tablePassthroughProps"
|
|
61
|
+
v-bind="{ ...tableModeBindings, ...tablePassthroughProps }"
|
|
61
62
|
@update:sort-by="handleSortChange"
|
|
62
63
|
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
63
64
|
@update:current-page="apiCurrentPage = $event"
|
|
@@ -137,113 +138,16 @@
|
|
|
137
138
|
</div>
|
|
138
139
|
</template>
|
|
139
140
|
|
|
140
|
-
<!--
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
<!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
|
|
142
|
+
bootstrap-vue-next, in every payload shape, so DXTable resolves
|
|
143
|
+
those columns itself (#121). A consumer's own cell(<key>) slot
|
|
144
|
+
wins — `dottedCellFields` excludes it, and the forwarding below
|
|
145
|
+
would otherwise declare the same slot name twice. -->
|
|
144
146
|
<template
|
|
145
|
-
v-for="
|
|
146
|
-
:key="
|
|
147
|
-
#[
|
|
148
|
-
>
|
|
149
|
-
<!--
|
|
150
|
-
@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.
|
|
151
|
-
-->
|
|
152
|
-
<slot :name="name" v-bind="slotProps" />
|
|
153
|
-
</template>
|
|
154
|
-
</DTable>
|
|
155
|
-
|
|
156
|
-
<!-- Client-Side Mode: Local filtering, sorting, pagination -->
|
|
157
|
-
<DTable
|
|
158
|
-
v-else-if="isClientSideMode"
|
|
159
|
-
:key="tableSlotSignature($slots)"
|
|
160
|
-
:items="clientSidePaginatedItems"
|
|
161
|
-
:fields="fields"
|
|
162
|
-
:sort-by="effectiveSortBy"
|
|
163
|
-
:multisort="false"
|
|
164
|
-
:no-local-sorting="true"
|
|
165
|
-
:no-sortable-icon="true"
|
|
166
|
-
:striped="striped"
|
|
167
|
-
:hover="hover"
|
|
168
|
-
:responsive="responsive"
|
|
169
|
-
:tbody-tr-class="composeRowClass"
|
|
170
|
-
v-bind="tablePassthroughProps"
|
|
171
|
-
@update:sort-by="handleSortChange"
|
|
172
|
-
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
173
|
-
@row-clicked="handleRowClick"
|
|
174
|
-
>
|
|
175
|
-
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
176
|
-
consumer's own thead-top content is COMPOSED above it rather
|
|
177
|
-
than being dropped: a grouped column-header banner or a pinned
|
|
178
|
-
totals row sits above the headers where it belongs (#120). -->
|
|
179
|
-
<template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
|
|
180
|
-
<!--
|
|
181
|
-
@slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
|
|
182
|
-
@binding {object} columns The number of columns in the table.
|
|
183
|
-
@binding {object} fields The table's fields.
|
|
184
|
-
-->
|
|
185
|
-
<slot name="thead-top" v-bind="theadScope" />
|
|
186
|
-
|
|
187
|
-
<tr v-if="hasFilters" class="filter-row">
|
|
188
|
-
<th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
|
|
189
|
-
<!-- Text Filter -->
|
|
190
|
-
<DFormInput
|
|
191
|
-
v-if="field.filter === 'text'"
|
|
192
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
193
|
-
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
194
|
-
size="sm"
|
|
195
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
196
|
-
/>
|
|
197
|
-
|
|
198
|
-
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
199
|
-
<DAutocomplete
|
|
200
|
-
v-else-if="field.filter === 'select'"
|
|
201
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
202
|
-
:options="getFieldFilterOptions(field)"
|
|
203
|
-
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
204
|
-
size="sm"
|
|
205
|
-
open-on-focus
|
|
206
|
-
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
207
|
-
/>
|
|
208
|
-
|
|
209
|
-
<!-- Number Filter -->
|
|
210
|
-
<DFormInput
|
|
211
|
-
v-else-if="field.filter === 'number'"
|
|
212
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
213
|
-
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
214
|
-
type="number"
|
|
215
|
-
size="sm"
|
|
216
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
217
|
-
/>
|
|
218
|
-
|
|
219
|
-
<!-- Date Filter -->
|
|
220
|
-
<DFormInput
|
|
221
|
-
v-else-if="field.filter === 'date'"
|
|
222
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
223
|
-
type="date"
|
|
224
|
-
size="sm"
|
|
225
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
226
|
-
/>
|
|
227
|
-
|
|
228
|
-
<!-- No filter for this column -->
|
|
229
|
-
<div v-else></div>
|
|
230
|
-
</th>
|
|
231
|
-
</tr>
|
|
232
|
-
</template>
|
|
233
|
-
|
|
234
|
-
<!-- Custom headers for all fields -->
|
|
235
|
-
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
236
|
-
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
237
|
-
<div class="flex-grow-1">
|
|
238
|
-
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
239
|
-
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
240
|
-
</div>
|
|
241
|
-
<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;">
|
|
242
|
-
<span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
|
|
243
|
-
<span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</template>
|
|
147
|
+
v-for="field in dottedCellFields($slots)"
|
|
148
|
+
:key="`dotted-cell-${field.key}`"
|
|
149
|
+
#[`cell(${field.key})`]="{ item }"
|
|
150
|
+
>{{ fieldValueOf(item, field.key) }}</template>
|
|
247
151
|
|
|
248
152
|
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
249
153
|
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
@@ -261,435 +165,85 @@
|
|
|
261
165
|
</template>
|
|
262
166
|
</DTable>
|
|
263
167
|
|
|
264
|
-
<!--
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
:
|
|
272
|
-
:
|
|
273
|
-
:
|
|
274
|
-
:
|
|
275
|
-
:
|
|
276
|
-
:
|
|
277
|
-
:
|
|
278
|
-
:
|
|
279
|
-
|
|
280
|
-
@
|
|
281
|
-
|
|
282
|
-
@row-clicked="handleRowClick"
|
|
283
|
-
>
|
|
284
|
-
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
285
|
-
consumer's own thead-top content is COMPOSED above it rather
|
|
286
|
-
than being dropped: a grouped column-header banner or a pinned
|
|
287
|
-
totals row sits above the headers where it belongs (#120). -->
|
|
288
|
-
<template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
|
|
289
|
-
<!--
|
|
290
|
-
@slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
|
|
291
|
-
@binding {object} columns The number of columns in the table.
|
|
292
|
-
@binding {object} fields The table's fields.
|
|
293
|
-
-->
|
|
294
|
-
<slot name="thead-top" v-bind="theadScope" />
|
|
295
|
-
|
|
296
|
-
<tr v-if="hasFilters" class="filter-row">
|
|
297
|
-
<th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
|
|
298
|
-
<!-- Text Filter -->
|
|
299
|
-
<DFormInput
|
|
300
|
-
v-if="field.filter === 'text'"
|
|
301
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
302
|
-
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
303
|
-
size="sm"
|
|
304
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
305
|
-
/>
|
|
306
|
-
|
|
307
|
-
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
308
|
-
<DAutocomplete
|
|
309
|
-
v-else-if="field.filter === 'select'"
|
|
310
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
311
|
-
:options="getFieldFilterOptions(field)"
|
|
312
|
-
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
313
|
-
size="sm"
|
|
314
|
-
open-on-focus
|
|
315
|
-
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
316
|
-
/>
|
|
317
|
-
|
|
318
|
-
<!-- Number Filter -->
|
|
319
|
-
<DFormInput
|
|
320
|
-
v-else-if="field.filter === 'number'"
|
|
321
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
322
|
-
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
323
|
-
type="number"
|
|
324
|
-
size="sm"
|
|
325
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
326
|
-
/>
|
|
327
|
-
|
|
328
|
-
<!-- Date Filter -->
|
|
329
|
-
<DFormInput
|
|
330
|
-
v-else-if="field.filter === 'date'"
|
|
331
|
-
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
332
|
-
type="date"
|
|
333
|
-
size="sm"
|
|
334
|
-
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
335
|
-
/>
|
|
336
|
-
|
|
337
|
-
<!-- No filter for this column -->
|
|
338
|
-
<div v-else></div>
|
|
339
|
-
</th>
|
|
340
|
-
</tr>
|
|
341
|
-
</template>
|
|
342
|
-
|
|
343
|
-
<!-- Custom headers for all fields -->
|
|
344
|
-
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
345
|
-
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
346
|
-
<div class="flex-grow-1">
|
|
347
|
-
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
348
|
-
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
349
|
-
</div>
|
|
350
|
-
<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;">
|
|
351
|
-
<span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
|
|
352
|
-
<span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
|
|
353
|
-
</div>
|
|
354
|
-
</div>
|
|
355
|
-
</template>
|
|
356
|
-
|
|
357
|
-
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
358
|
-
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
359
|
-
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
360
|
-
See `isTableSlot` for the two DXTable renders itself. -->
|
|
361
|
-
<template
|
|
362
|
-
v-for="name in forwardableSlotNames($slots)"
|
|
363
|
-
:key="name"
|
|
364
|
-
#[name]="slotProps"
|
|
365
|
-
>
|
|
366
|
-
<!--
|
|
367
|
-
@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.
|
|
368
|
-
-->
|
|
369
|
-
<slot :name="name" v-bind="slotProps" />
|
|
370
|
-
</template>
|
|
371
|
-
</DTable>
|
|
372
|
-
|
|
373
|
-
<!-- Pagination and Controls (Client-Side mode) -->
|
|
374
|
-
<div v-if="isClientSideMode" class="mt-3">
|
|
375
|
-
<!-- Top row: Pagination and Per-page selector -->
|
|
376
|
-
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
377
|
-
<!-- Pagination controls (only when multiple pages) -->
|
|
378
|
-
<DPagination
|
|
379
|
-
v-if="showPagination && clientSidePagination.total > clientSidePagination.per_page"
|
|
380
|
-
:model-value="clientSidePagination.current_page"
|
|
381
|
-
:total-rows="clientSidePagination.total"
|
|
382
|
-
:per-page="clientSidePagination.per_page"
|
|
383
|
-
size="sm"
|
|
384
|
-
@update:model-value="handleClientSidePageChange"
|
|
385
|
-
/>
|
|
386
|
-
<div v-else></div>
|
|
387
|
-
|
|
388
|
-
<!-- Per-page selector -->
|
|
389
|
-
<div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
|
|
390
|
-
<label for="perPageSelectClientSide" class="mb-0 small text-muted">Per page</label>
|
|
391
|
-
<DFormSelect
|
|
392
|
-
id="perPageSelectClientSide"
|
|
393
|
-
:model-value="effectivePerPage"
|
|
394
|
-
:options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
|
|
395
|
-
size="sm"
|
|
396
|
-
style="width: 85px;"
|
|
397
|
-
@update:model-value="handlePerPageChange"
|
|
398
|
-
/>
|
|
399
|
-
</div>
|
|
400
|
-
</div>
|
|
401
|
-
|
|
402
|
-
<!-- Bottom row: Info text -->
|
|
403
|
-
<div class="small text-muted">
|
|
404
|
-
<div>
|
|
405
|
-
<template v-if="clientSidePagination.total > clientSidePagination.per_page">
|
|
406
|
-
{{ clientSidePagination.from }} to {{ clientSidePagination.to }} out of {{ clientSidePagination.total }} {{ clientSidePagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
407
|
-
</template>
|
|
408
|
-
<template v-else-if="clientSidePagination.total === 1">
|
|
409
|
-
{{ clientSidePagination.total }} {{ singularItemName }}.
|
|
410
|
-
</template>
|
|
411
|
-
<template v-else>
|
|
412
|
-
{{ clientSidePagination.total }} {{ pluralItemName }}.
|
|
413
|
-
</template>
|
|
414
|
-
</div>
|
|
415
|
-
<div v-if="hasActiveFilters && clientSidePagination.total_unfiltered">
|
|
416
|
-
<small>Filtered from {{ clientSidePagination.total_unfiltered }} {{ clientSidePagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
|
|
417
|
-
</div>
|
|
418
|
-
</div>
|
|
419
|
-
</div>
|
|
420
|
-
|
|
421
|
-
<!-- Pagination and Controls (Inertia mode) -->
|
|
422
|
-
<div v-if="isInertiaMode && pagination" class="mt-3">
|
|
423
|
-
<!-- Top row: Pagination and Per-page selector -->
|
|
424
|
-
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
425
|
-
<!-- Pagination controls (only when multiple pages) -->
|
|
426
|
-
<DPagination
|
|
427
|
-
v-if="showPagination && pagination.total > pagination.per_page"
|
|
428
|
-
:model-value="pagination.current_page"
|
|
429
|
-
:total-rows="pagination.total"
|
|
430
|
-
:per-page="pagination.per_page"
|
|
431
|
-
size="sm"
|
|
432
|
-
@update:model-value="handlePageChange"
|
|
433
|
-
/>
|
|
434
|
-
<div v-else></div>
|
|
435
|
-
|
|
436
|
-
<!-- Per-page selector -->
|
|
437
|
-
<div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
|
|
438
|
-
<label for="perPageSelect" class="mb-0 small text-muted">Per page</label>
|
|
439
|
-
<DFormSelect
|
|
440
|
-
id="perPageSelect"
|
|
441
|
-
:model-value="effectivePerPage"
|
|
442
|
-
:options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
|
|
443
|
-
size="sm"
|
|
444
|
-
style="width: 85px;"
|
|
445
|
-
@update:model-value="handlePerPageChange"
|
|
446
|
-
/>
|
|
447
|
-
</div>
|
|
448
|
-
</div>
|
|
449
|
-
|
|
450
|
-
<!-- Bottom row: Info text -->
|
|
451
|
-
<div class="small text-muted">
|
|
452
|
-
<div>
|
|
453
|
-
<template v-if="pagination.total > pagination.per_page">
|
|
454
|
-
{{ pagination.from }} to {{ pagination.to }} out of {{ pagination.total }} {{ pagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
455
|
-
</template>
|
|
456
|
-
<template v-else-if="pagination.total === 1">
|
|
457
|
-
{{ pagination.total }} {{ singularItemName }}.
|
|
458
|
-
</template>
|
|
459
|
-
<template v-else>
|
|
460
|
-
{{ pagination.total }} {{ pluralItemName }}.
|
|
461
|
-
</template>
|
|
462
|
-
</div>
|
|
463
|
-
<div v-if="hasActiveFilters && pagination.total_unfiltered">
|
|
464
|
-
<small>Filtered from {{ pagination.total_unfiltered }} {{ pagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
|
|
465
|
-
</div>
|
|
466
|
-
</div>
|
|
467
|
-
</div>
|
|
468
|
-
|
|
469
|
-
<!-- Pagination and Controls (API mode) -->
|
|
470
|
-
<div v-if="isProviderMode && providerPagination" class="mt-3">
|
|
471
|
-
<!-- Top row: Pagination and Per-page selector -->
|
|
472
|
-
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
473
|
-
<!-- Pagination controls (only when multiple pages) -->
|
|
474
|
-
<DPagination
|
|
475
|
-
v-if="showPagination && providerPagination.total > providerPagination.per_page"
|
|
476
|
-
:model-value="providerPagination.current_page"
|
|
477
|
-
:total-rows="providerPagination.total"
|
|
478
|
-
:per-page="providerPagination.per_page"
|
|
479
|
-
size="sm"
|
|
480
|
-
@update:model-value="handleApiPageChange"
|
|
481
|
-
/>
|
|
482
|
-
<div v-else></div>
|
|
483
|
-
|
|
484
|
-
<!-- Per-page selector -->
|
|
485
|
-
<div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
|
|
486
|
-
<label for="perPageSelectApi" class="mb-0 small text-muted">Per page</label>
|
|
487
|
-
<DFormSelect
|
|
488
|
-
id="perPageSelectApi"
|
|
489
|
-
:model-value="effectivePerPage"
|
|
490
|
-
:options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
|
|
491
|
-
size="sm"
|
|
492
|
-
style="width: 85px;"
|
|
493
|
-
@update:model-value="handlePerPageChange"
|
|
494
|
-
/>
|
|
495
|
-
</div>
|
|
496
|
-
</div>
|
|
497
|
-
|
|
498
|
-
<!-- Bottom row: Info text -->
|
|
499
|
-
<div class="small text-muted">
|
|
500
|
-
<div>
|
|
501
|
-
<template v-if="providerPagination.total > providerPagination.per_page">
|
|
502
|
-
{{ providerPagination.from }} to {{ providerPagination.to }} out of {{ providerPagination.total }} {{ providerPagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
503
|
-
</template>
|
|
504
|
-
<template v-else-if="providerPagination.total === 1">
|
|
505
|
-
{{ providerPagination.total }} {{ singularItemName }}.
|
|
506
|
-
</template>
|
|
507
|
-
<template v-else>
|
|
508
|
-
{{ providerPagination.total }} {{ pluralItemName }}.
|
|
509
|
-
</template>
|
|
510
|
-
</div>
|
|
511
|
-
<div v-if="hasActiveFilters && providerPagination.total_unfiltered">
|
|
512
|
-
<small>Filtered from {{ providerPagination.total_unfiltered }} {{ providerPagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
|
|
513
|
-
</div>
|
|
514
|
-
</div>
|
|
515
|
-
</div>
|
|
168
|
+
<!-- Pagination + per-page footer, rendered ONCE for all three
|
|
169
|
+
data modes (#123). `activePagination` picks the mode's
|
|
170
|
+
PaginationData; `handleActivePageChange` routes the page
|
|
171
|
+
event to the right per-mode handler. Previously three
|
|
172
|
+
byte-identical copies, one per mode. -->
|
|
173
|
+
<DXTablePagination
|
|
174
|
+
v-if="activePagination"
|
|
175
|
+
:pagination="activePagination"
|
|
176
|
+
:per-page="effectivePerPage"
|
|
177
|
+
:per-page-options="perPageOptions"
|
|
178
|
+
:show-pagination="showPagination"
|
|
179
|
+
:show-per-page-selector="shouldShowPerPageSelector"
|
|
180
|
+
:singular-item-name="singularItemName"
|
|
181
|
+
:plural-item-name="pluralItemName"
|
|
182
|
+
:has-active-filters="hasActiveFilters"
|
|
183
|
+
@page-change="handleActivePageChange"
|
|
184
|
+
@per-page-change="handlePerPageChange"
|
|
185
|
+
/>
|
|
516
186
|
</DXTableShell>
|
|
517
187
|
</DCol>
|
|
518
188
|
</DRow>
|
|
519
189
|
|
|
520
|
-
<!-- Edit
|
|
521
|
-
|
|
190
|
+
<!-- Edit/create modal (only when editFields provided). The consumer's
|
|
191
|
+
edit-value / edit-span / tab-* slots are forwarded through to it
|
|
192
|
+
generically; it maps them onto DXForm and provides the row context
|
|
193
|
+
+ a close handle (#129). -->
|
|
194
|
+
<DXTableEditorModal
|
|
522
195
|
v-if="editFields && editFields.length > 0"
|
|
523
|
-
v-model="showEditModal"
|
|
196
|
+
v-model:show="showEditModal"
|
|
197
|
+
v-model:active-tab="activeTabIndex"
|
|
524
198
|
:title="computedModalTitle"
|
|
525
199
|
:size="editModalSize"
|
|
200
|
+
:loading="editLoading"
|
|
201
|
+
:form="editForm"
|
|
202
|
+
:form-instance-key="editFormInstanceKey"
|
|
203
|
+
:fields="editFields"
|
|
204
|
+
:tabs="editTabs"
|
|
205
|
+
:item="selectedItem"
|
|
206
|
+
:is-create-mode="isCreateMode"
|
|
207
|
+
:pending-action="pendingAction"
|
|
208
|
+
:delete-url="deleteUrl"
|
|
209
|
+
@save="handleEditSave"
|
|
210
|
+
@cancel="handleEditCancel"
|
|
211
|
+
@delete="handleDelete"
|
|
526
212
|
>
|
|
527
|
-
<!--
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise).
|
|
537
|
-
|
|
538
|
-
Keyed per modal open: the form OBJECT is created once and then
|
|
539
|
-
reseeded in place for each row, so without a key Vue reuses the
|
|
540
|
-
same DXField instances across records — and any per-field UI
|
|
541
|
-
state rides along with them. That leaked a revealed password
|
|
542
|
-
from one row into the next. The key gives each record a fresh
|
|
543
|
-
field subtree; the data still lives in `editForm`, so nothing
|
|
544
|
-
is lost by remounting. -->
|
|
545
|
-
<DXForm
|
|
546
|
-
v-if="editForm"
|
|
547
|
-
:key="editFormInstanceKey"
|
|
548
|
-
v-model:active-tab="activeTabIndex"
|
|
549
|
-
:form="editForm"
|
|
550
|
-
:fields="editFields"
|
|
551
|
-
:tabs="editTabs"
|
|
552
|
-
:context="selectedItem ?? undefined"
|
|
553
|
-
:show-submit="false"
|
|
554
|
-
@submit="handleEditSave"
|
|
213
|
+
<!--
|
|
214
|
+
@slot edit-value(<fieldKey>) / edit-span(<fieldKey>) /
|
|
215
|
+
tab-content|tab-before|tab-after(<tabKey>) — forwarded to the edit
|
|
216
|
+
modal (see DXTableEditorModal for their bindings).
|
|
217
|
+
-->
|
|
218
|
+
<template
|
|
219
|
+
v-for="name in editModalSlotNames($slots)"
|
|
220
|
+
:key="name"
|
|
221
|
+
#[name]="slotProps"
|
|
555
222
|
>
|
|
556
|
-
|
|
557
|
-
<template
|
|
558
|
-
v-for="key in editValueSlotKeys"
|
|
559
|
-
:key="`ev-${key}`"
|
|
560
|
-
#[`value(${key})`]="sp"
|
|
561
|
-
>
|
|
562
|
-
<!--
|
|
563
|
-
@slot Custom input for field `<key>` in the edit/create modal, forwarded to DXForm. Name it `edit-value(<fieldKey>)`.
|
|
564
|
-
@binding {object} item The row being edited (null in create mode).
|
|
565
|
-
@binding {any} value The current field value.
|
|
566
|
-
@binding {Function} update Call with a new value to update the field.
|
|
567
|
-
@binding {object} field The field definition.
|
|
568
|
-
-->
|
|
569
|
-
<slot
|
|
570
|
-
:name="`edit-value(${key})`"
|
|
571
|
-
:item="selectedItem"
|
|
572
|
-
:value="sp.value"
|
|
573
|
-
:update="sp.update"
|
|
574
|
-
:field="sp.field"
|
|
575
|
-
/>
|
|
576
|
-
</template>
|
|
577
|
-
|
|
578
|
-
<!-- Forward edit-span(key) → span(key) -->
|
|
579
|
-
<template
|
|
580
|
-
v-for="key in editSpanSlotKeys"
|
|
581
|
-
:key="`es-${key}`"
|
|
582
|
-
#[`span(${key})`]="sp"
|
|
583
|
-
>
|
|
584
|
-
<!--
|
|
585
|
-
@slot Full-width custom content for field `<key>` in the edit/create modal, forwarded to DXForm's span slot. Name it `edit-span(<fieldKey>)`.
|
|
586
|
-
@binding {object} item The row being edited (null in create mode).
|
|
587
|
-
@binding {any} value The current field value.
|
|
588
|
-
@binding {Function} update Call with a new value to update the field.
|
|
589
|
-
@binding {Function} close Call to close the edit modal.
|
|
590
|
-
-->
|
|
591
|
-
<slot
|
|
592
|
-
:name="`edit-span(${key})`"
|
|
593
|
-
:item="selectedItem"
|
|
594
|
-
:value="sp.value"
|
|
595
|
-
:update="sp.update"
|
|
596
|
-
:close="handleEditCancel"
|
|
597
|
-
/>
|
|
598
|
-
</template>
|
|
599
|
-
|
|
600
|
-
<!-- Forward tab-content / tab-before / tab-after slots -->
|
|
601
|
-
<template
|
|
602
|
-
v-for="key in tabContentSlotKeys"
|
|
603
|
-
:key="`tc-${key}`"
|
|
604
|
-
#[`tab-content(${key})`]="sp"
|
|
605
|
-
>
|
|
606
|
-
<!--
|
|
607
|
-
@slot Replaces the auto-rendered fields of edit-modal tab `<key>` with custom content. Name it `tab-content(<tabKey>)`.
|
|
608
|
-
@binding {object} item The row being edited (null in create mode).
|
|
609
|
-
@binding {object} tab The tab definition.
|
|
610
|
-
-->
|
|
611
|
-
<slot :name="`tab-content(${key})`" :item="selectedItem" :tab="sp.tab" />
|
|
612
|
-
</template>
|
|
613
|
-
<template
|
|
614
|
-
v-for="key in tabBeforeSlotKeys"
|
|
615
|
-
:key="`tb-${key}`"
|
|
616
|
-
#[`tab-before(${key})`]="sp"
|
|
617
|
-
>
|
|
618
|
-
<!--
|
|
619
|
-
@slot Custom content rendered before the fields of edit-modal tab `<key>`. Name it `tab-before(<tabKey>)`.
|
|
620
|
-
@binding {object} item The row being edited (null in create mode).
|
|
621
|
-
@binding {object} tab The tab definition.
|
|
622
|
-
-->
|
|
623
|
-
<slot :name="`tab-before(${key})`" :item="selectedItem" :tab="sp.tab" />
|
|
624
|
-
</template>
|
|
625
|
-
<template
|
|
626
|
-
v-for="key in tabAfterSlotKeys"
|
|
627
|
-
:key="`taf-${key}`"
|
|
628
|
-
#[`tab-after(${key})`]="sp"
|
|
629
|
-
>
|
|
630
|
-
<!--
|
|
631
|
-
@slot Custom content rendered after the fields of edit-modal tab `<key>`. Name it `tab-after(<tabKey>)`.
|
|
632
|
-
@binding {object} item The row being edited (null in create mode).
|
|
633
|
-
@binding {object} tab The tab definition.
|
|
634
|
-
-->
|
|
635
|
-
<slot :name="`tab-after(${key})`" :item="selectedItem" :tab="sp.tab" />
|
|
636
|
-
</template>
|
|
637
|
-
</DXForm>
|
|
638
|
-
|
|
639
|
-
<template #footer>
|
|
640
|
-
<div class="d-flex justify-content-between w-100">
|
|
641
|
-
<div>
|
|
642
|
-
<DButton
|
|
643
|
-
v-if="deleteUrl && !isCreateMode"
|
|
644
|
-
variant="danger"
|
|
645
|
-
:loading="pendingAction === 'delete'"
|
|
646
|
-
loading-text="Deleting..."
|
|
647
|
-
:disabled="editForm?.processing || editLoading"
|
|
648
|
-
@click="handleDelete"
|
|
649
|
-
>
|
|
650
|
-
Delete
|
|
651
|
-
</DButton>
|
|
652
|
-
</div>
|
|
653
|
-
<div class="d-flex gap-2">
|
|
654
|
-
<DButton variant="secondary" @click="handleEditCancel">
|
|
655
|
-
Cancel
|
|
656
|
-
</DButton>
|
|
657
|
-
<DButton
|
|
658
|
-
variant="primary"
|
|
659
|
-
:loading="pendingAction === 'save'"
|
|
660
|
-
:loading-text="isCreateMode ? 'Creating...' : 'Saving...'"
|
|
661
|
-
:disabled="editForm?.processing || editLoading"
|
|
662
|
-
@click="handleEditSave"
|
|
663
|
-
>
|
|
664
|
-
{{ isCreateMode ? 'Create' : 'Save Changes' }}
|
|
665
|
-
</DButton>
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
223
|
+
<slot :name="name" v-bind="slotProps" />
|
|
668
224
|
</template>
|
|
669
|
-
</
|
|
225
|
+
</DXTableEditorModal>
|
|
670
226
|
</DContainer>
|
|
671
227
|
</template>
|
|
672
228
|
|
|
673
229
|
<script setup lang="ts" generic="T = any">
|
|
674
|
-
import { computed, getCurrentInstance, ref, watch
|
|
230
|
+
import { computed, getCurrentInstance, ref, watch } from "vue";
|
|
675
231
|
import { router } from "@inertiajs/vue3";
|
|
676
232
|
import axios from "axios";
|
|
677
233
|
import pluralize from "pluralize";
|
|
678
|
-
import {
|
|
679
|
-
import {
|
|
234
|
+
import { useResourceEditor } from "../../composables/useResourceEditor";
|
|
235
|
+
import { getByPath } from "../../utils/objectPath";
|
|
680
236
|
import DContainer from "../base/DContainer.vue";
|
|
681
237
|
import DRow from "../base/DRow.vue";
|
|
682
238
|
import DCol from "../base/DCol.vue";
|
|
683
239
|
import DXTableShell from "./DXTableShell.vue";
|
|
684
240
|
import DSpinner from "../base/DSpinner.vue";
|
|
685
241
|
import DTable from "../base/DTable.vue";
|
|
686
|
-
import DPagination from "../base/DPagination.vue";
|
|
687
242
|
import DFormInput from "../base/DFormInput.vue";
|
|
688
|
-
import DFormSelect from "../base/DFormSelect.vue";
|
|
689
243
|
import DAutocomplete from "../base/DAutocomplete.vue";
|
|
690
|
-
import DModal from "../base/DModal.vue";
|
|
691
244
|
import DButton from "../base/DButton.vue";
|
|
692
|
-
import
|
|
245
|
+
import DXTablePagination from "./DXTablePagination.vue";
|
|
246
|
+
import DXTableEditorModal from "./DXTableEditorModal.vue";
|
|
693
247
|
export type FilterType = 'text' | 'select' | 'number' | 'date' | false;
|
|
694
248
|
|
|
695
249
|
export interface FilterOption {
|
|
@@ -1222,10 +776,11 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1222
776
|
// `customer_id`). Match on the filter key when the row has it,
|
|
1223
777
|
// otherwise fall back to the column's own value — which is also the
|
|
1224
778
|
// one the user can actually see and type against.
|
|
779
|
+
// Both lookups are dot-path aware, so a nested payload filters (#121).
|
|
1225
780
|
const itemValue =
|
|
1226
|
-
field &&
|
|
1227
|
-
? (item
|
|
1228
|
-
: (item
|
|
781
|
+
field && hasFieldValue(item, key)
|
|
782
|
+
? fieldValueOf(item, key)
|
|
783
|
+
: fieldValueOf(item, field?.key ?? key);
|
|
1229
784
|
|
|
1230
785
|
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
1231
786
|
// MATCHES an absent value rather than failing on it.
|
|
@@ -1279,13 +834,16 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1279
834
|
const direction = order === 'desc' ? -1 : 1;
|
|
1280
835
|
|
|
1281
836
|
return items.sort((a, b) => {
|
|
1282
|
-
|
|
1283
|
-
const
|
|
837
|
+
// Dot-path aware, so `paid_by.card` sorts a nested payload (#121).
|
|
838
|
+
const aVal = fieldValueOf(a, key);
|
|
839
|
+
const bVal = fieldValueOf(b, key);
|
|
1284
840
|
|
|
1285
841
|
// Handle null/undefined
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
842
|
+
const aEmpty = aVal === null || aVal === undefined;
|
|
843
|
+
const bEmpty = bVal === null || bVal === undefined;
|
|
844
|
+
if (aEmpty && bEmpty) return 0;
|
|
845
|
+
if (aEmpty) return direction;
|
|
846
|
+
if (bEmpty) return -direction;
|
|
1289
847
|
|
|
1290
848
|
// Numeric comparison
|
|
1291
849
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
@@ -1300,6 +858,54 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1300
858
|
// API mode filter values (extracted from responses)
|
|
1301
859
|
const apiFilterValues = ref<Record<string, string[]>>({});
|
|
1302
860
|
|
|
861
|
+
/*
|
|
862
|
+
* Resolving a field's value from a row, dot-path aware (#121).
|
|
863
|
+
*
|
|
864
|
+
* A key containing a dot (`paid_by.card`) was unusable. Two layers disagreed:
|
|
865
|
+
* bootstrap-vue-next's `mapItem` UN-flattens any item key containing a dot
|
|
866
|
+
* (`{'paid_by.card': v}` becomes `{paid_by: {card: v}}`) and then its cell reads
|
|
867
|
+
* `item['paid_by.card']` FLAT and gets undefined — so bvn renders a dotted key
|
|
868
|
+
* as an empty cell in BOTH payload shapes. Meanwhile our own client-side sort
|
|
869
|
+
* and filter also read flat, which a NESTED payload (what Laravel serialises for
|
|
870
|
+
* a relation) never satisfies. Consumers worked around it by flattening every
|
|
871
|
+
* nested key to an `_x` alias in each client-side port.
|
|
872
|
+
*
|
|
873
|
+
* LITERAL key first, then dot-path. That single order covers every shape:
|
|
874
|
+
* an ordinary key (`name`), a dots-flat payload (the literal `paid_by.card`
|
|
875
|
+
* key), and a nested payload (walk `paid_by` -> `card`). It also means bvn's
|
|
876
|
+
* un-flattening is harmless rather than hostile — mapItem normalises flat to
|
|
877
|
+
* nested, and the path read finds it either way.
|
|
878
|
+
*/
|
|
879
|
+
const fieldValueOf = (item: unknown, key: string): any => {
|
|
880
|
+
if (item === null || item === undefined) return undefined;
|
|
881
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
882
|
+
return (item as Record<string, any>)[key];
|
|
883
|
+
}
|
|
884
|
+
return getByPath(item as Record<string, any>, key);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/*
|
|
888
|
+
* Whether a row carries a value for `key` at all — presence, not truthiness.
|
|
889
|
+
* Mirrors the `key in item` check this replaced, extended to dot paths.
|
|
890
|
+
*/
|
|
891
|
+
const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
892
|
+
if (item === null || item === undefined) return false;
|
|
893
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) return true;
|
|
894
|
+
return getByPath(item as Record<string, any>, key) !== undefined;
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
/*
|
|
898
|
+
* Dotted-key columns DXTable must render itself, because bvn renders them empty
|
|
899
|
+
* (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
|
|
900
|
+
* forwarded separately, and declaring the same slot name twice would clobber it.
|
|
901
|
+
*
|
|
902
|
+
* A FUNCTION, not a computed, for the same reason as `forwardableSlotNames`:
|
|
903
|
+
* `useSlots()` returns an object Vue mutates in place with nothing to track, so
|
|
904
|
+
* a computed over its keys is captured on first render and never updates (#114).
|
|
905
|
+
*/
|
|
906
|
+
const dottedCellFields = (slots: Record<string, unknown>): TableField[] =>
|
|
907
|
+
props.fields.filter((field) => field.key.includes('.') && !slots[`cell(${field.key})`]);
|
|
908
|
+
|
|
1303
909
|
// Computed: Get effective filter options for a field
|
|
1304
910
|
// The key a column's filter is SENT under, which is not always the column's own
|
|
1305
911
|
// key: a display column can filter on a different server param via `filterKey`.
|
|
@@ -1713,6 +1319,62 @@ const handleApiPageChange = (page: number) => {
|
|
|
1713
1319
|
// BTable should automatically call provider when currentPage prop changes
|
|
1714
1320
|
};
|
|
1715
1321
|
|
|
1322
|
+
/*
|
|
1323
|
+
* The per-mode props for the ONE inner `<DTable>` (#123). DXTable used to carry
|
|
1324
|
+
* three near-identical `<DTable>` blocks — provider / client-side / inertia —
|
|
1325
|
+
* differing only in these few bindings, so every table fix had to be written
|
|
1326
|
+
* three times and could silently miss a mode. This reproduces each old block's
|
|
1327
|
+
* bindings EXACTLY (a faithful collapse, not a behaviour change):
|
|
1328
|
+
*
|
|
1329
|
+
* - provider: BTable drives its own pagination, so it needs `provider`,
|
|
1330
|
+
* `currentPage` and `perPage`; `busy` is the raw prop (two-way with BTable's
|
|
1331
|
+
* provider loading via `@update:busy`).
|
|
1332
|
+
* - client-side: rows are pre-sliced by `clientSidePaginatedItems`, and local
|
|
1333
|
+
* sorting is off (we sort in `clientSideSortedItems`).
|
|
1334
|
+
* - inertia: server-paginated `items`, local sorting off, `busy` is
|
|
1335
|
+
* `effectiveBusy` (the `loading` prop in this mode).
|
|
1336
|
+
*
|
|
1337
|
+
* A key absent from the returned object is simply not bound, which is what the
|
|
1338
|
+
* original blocks did (e.g. non-provider modes never set `provider`/`currentPage`,
|
|
1339
|
+
* so BTable falls back to `items` and its own page defaults).
|
|
1340
|
+
*/
|
|
1341
|
+
const tableModeBindings = computed<Record<string, unknown>>(() => {
|
|
1342
|
+
if (isProviderMode.value) {
|
|
1343
|
+
return {
|
|
1344
|
+
provider: effectiveProvider.value,
|
|
1345
|
+
currentPage: apiCurrentPage.value,
|
|
1346
|
+
perPage: effectivePerPage.value,
|
|
1347
|
+
busy: props.busy,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
if (isClientSideMode.value) {
|
|
1351
|
+
return { items: clientSidePaginatedItems.value, noLocalSorting: true };
|
|
1352
|
+
}
|
|
1353
|
+
// inertia
|
|
1354
|
+
return { items: props.items, noLocalSorting: true, busy: effectiveBusy.value };
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
/*
|
|
1358
|
+
* The pagination metadata the single footer renders, by mode (#123). Null means
|
|
1359
|
+
* no footer: only provider mode has that case (a custom provider with no
|
|
1360
|
+
* `pagination` prop). Client-side always has a computed object; inertia's
|
|
1361
|
+
* `pagination` prop carries a default object, so both always render a footer —
|
|
1362
|
+
* matching the three original per-mode `v-if`s exactly.
|
|
1363
|
+
*/
|
|
1364
|
+
const activePagination = computed<PaginationData | null>(() => {
|
|
1365
|
+
if (isClientSideMode.value) return clientSidePagination.value;
|
|
1366
|
+
if (isInertiaMode.value) return props.pagination;
|
|
1367
|
+
if (isProviderMode.value) return providerPagination.value;
|
|
1368
|
+
return null;
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// Route the footer's page-change to the right per-mode handler.
|
|
1372
|
+
const handleActivePageChange = (page: number) => {
|
|
1373
|
+
if (isClientSideMode.value) return handleClientSidePageChange(page);
|
|
1374
|
+
if (isProviderMode.value) return handleApiPageChange(page);
|
|
1375
|
+
return handlePageChange(page); // inertia
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1716
1378
|
const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
1717
1379
|
// ENFORCE single-column sorting: keep only the last clicked column
|
|
1718
1380
|
// BTable may send multiple columns despite multisort: false
|
|
@@ -1855,6 +1517,7 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1855
1517
|
{
|
|
1856
1518
|
page: 1, // Reset to first page when filtering
|
|
1857
1519
|
filters: newFilters,
|
|
1520
|
+
perPage: effectivePerPage.value,
|
|
1858
1521
|
...sortParams(effectiveSortBy.value),
|
|
1859
1522
|
},
|
|
1860
1523
|
{ preserveState: true }
|
|
@@ -1887,121 +1550,6 @@ const refresh = () => {
|
|
|
1887
1550
|
}
|
|
1888
1551
|
};
|
|
1889
1552
|
|
|
1890
|
-
// Edit Modal State
|
|
1891
|
-
const showEditModal = ref(false);
|
|
1892
|
-
|
|
1893
|
-
// Bumped on every modal open (edit or create) to key the DXForm subtree, so
|
|
1894
|
-
// each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
|
|
1895
|
-
// which reseeds the SAME record — remounting there would discard edits the
|
|
1896
|
-
// user has already made while the fetch was in flight.
|
|
1897
|
-
const editFormInstanceKey = ref(0);
|
|
1898
|
-
const selectedItem = ref<T | null>(null);
|
|
1899
|
-
const editForm = ref<any>(null);
|
|
1900
|
-
const activeTabIndex = ref(0);
|
|
1901
|
-
const isCreateMode = ref(false);
|
|
1902
|
-
|
|
1903
|
-
// Which modal action is in flight, so the Save and Delete buttons show their
|
|
1904
|
-
// own loading label independently. `editForm.processing` is shared by every
|
|
1905
|
-
// request the form makes, so it can't tell Save from Delete on its own.
|
|
1906
|
-
const pendingAction = ref<'save' | 'delete' | null>(null);
|
|
1907
|
-
|
|
1908
|
-
// True while the edit modal is fetching the full record via `showUrl`.
|
|
1909
|
-
const editLoading = ref(false);
|
|
1910
|
-
|
|
1911
|
-
// Monotonic token so a slow fetch for a previously-opened row can't overwrite
|
|
1912
|
-
// the form after the user has since opened a different row.
|
|
1913
|
-
let editFetchToken = 0;
|
|
1914
|
-
|
|
1915
|
-
// Toast (may not be available in test environment)
|
|
1916
|
-
let createToast: ((obj: any) => any) | undefined;
|
|
1917
|
-
try {
|
|
1918
|
-
const toast = useToast();
|
|
1919
|
-
createToast = toast.create;
|
|
1920
|
-
} catch (e) {
|
|
1921
|
-
// BApp not available (test environment or missing setup)
|
|
1922
|
-
createToast = undefined;
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
// The edit/create form rendering is delegated to DXForm, which
|
|
1926
|
-
// owns field/tab visibility, dynamic labels/hints, conditional fields,
|
|
1927
|
-
// and auto-switching to the first tab with a validation error.
|
|
1928
|
-
|
|
1929
|
-
// Forward only the keyed edit slots the consumer actually provided, so
|
|
1930
|
-
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1931
|
-
// a real custom-value override.
|
|
1932
|
-
/*
|
|
1933
|
-
* Seeding an EDIT form from a row (#117).
|
|
1934
|
-
*
|
|
1935
|
-
* `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
|
|
1936
|
-
* from "the row's value IS null", so an explicitly-null column gets overwritten
|
|
1937
|
-
* by the field's default — a value the user never saw, on a field they may not
|
|
1938
|
-
* even be able to see. Presence, not nullishness, decides.
|
|
1939
|
-
*/
|
|
1940
|
-
const seedValueFor = (field: any, row: T): any => {
|
|
1941
|
-
if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
|
|
1942
|
-
return (row as any)[field.key];
|
|
1943
|
-
}
|
|
1944
|
-
return field.default ?? '';
|
|
1945
|
-
};
|
|
1946
|
-
|
|
1947
|
-
/*
|
|
1948
|
-
* Whether a field is currently VISIBLE, by the same rule DXForm renders by
|
|
1949
|
-
* (`when` + the legacy `show`), against the same model (context + live data).
|
|
1950
|
-
* Kept in step with DXForm.isFieldVisible.
|
|
1951
|
-
*/
|
|
1952
|
-
const editPredicateModel = computed(() => ({
|
|
1953
|
-
...((selectedItem.value as any) ?? {}),
|
|
1954
|
-
...(editForm.value?.data ?? {}),
|
|
1955
|
-
}));
|
|
1956
|
-
|
|
1957
|
-
const isEditFieldVisible = (field: any): boolean => {
|
|
1958
|
-
const when = field.when;
|
|
1959
|
-
const whenOk =
|
|
1960
|
-
when === undefined
|
|
1961
|
-
? true
|
|
1962
|
-
: typeof when === 'function'
|
|
1963
|
-
? when(editPredicateModel.value)
|
|
1964
|
-
: when;
|
|
1965
|
-
const showOk = field.show ? field.show() : true;
|
|
1966
|
-
return whenOk && showOk;
|
|
1967
|
-
};
|
|
1968
|
-
|
|
1969
|
-
// Fields whose value actually belongs in the payload. A presentational field
|
|
1970
|
-
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
1971
|
-
const submittableEditFields = computed(() =>
|
|
1972
|
-
(props.editFields ?? []).filter((field) => field.submit !== false),
|
|
1973
|
-
);
|
|
1974
|
-
|
|
1975
|
-
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
1976
|
-
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
1977
|
-
// its key back into the form data after seeding. Strip them on the way out.
|
|
1978
|
-
const nonSubmittedFieldKeys = computed(
|
|
1979
|
-
() =>
|
|
1980
|
-
new Set(
|
|
1981
|
-
(props.editFields ?? [])
|
|
1982
|
-
.filter(
|
|
1983
|
-
(field) =>
|
|
1984
|
-
// Presentational: lays the form out, holds no data (#110).
|
|
1985
|
-
field.submit === false ||
|
|
1986
|
-
// Hidden by `when` at submit time (#117). Submitting a field
|
|
1987
|
-
// the user cannot see is a silent write — and with `default`
|
|
1988
|
-
// set it writes a value they never chose. Omitting the key
|
|
1989
|
-
// leaves the stored value alone.
|
|
1990
|
-
!isEditFieldVisible(field),
|
|
1991
|
-
)
|
|
1992
|
-
.map((field) => field.key),
|
|
1993
|
-
),
|
|
1994
|
-
);
|
|
1995
|
-
|
|
1996
|
-
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
1997
|
-
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
1998
|
-
return Object.fromEntries(
|
|
1999
|
-
Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
|
|
2000
|
-
);
|
|
2001
|
-
};
|
|
2002
|
-
|
|
2003
|
-
const tableSlots = useSlots();
|
|
2004
|
-
|
|
2005
1553
|
/*
|
|
2006
1554
|
* Slot forwarding to the inner table.
|
|
2007
1555
|
*
|
|
@@ -2063,8 +1611,17 @@ const isTableSlot = (name: string) =>
|
|
|
2063
1611
|
const forwardableSlotNames = (slots: Record<string, unknown>): string[] =>
|
|
2064
1612
|
Object.keys(slots).filter((name) => isTableSlot(name));
|
|
2065
1613
|
|
|
2066
|
-
const tableSlotSignature = (slots: Record<string, unknown>): string =>
|
|
2067
|
-
|
|
1614
|
+
const tableSlotSignature = (slots: Record<string, unknown>): string => {
|
|
1615
|
+
// DXTable's OWN dotted-cell slots count too (#121). bvn captures its cell
|
|
1616
|
+
// slot set at mount (#114), so a dotted-key column that appears LATER —
|
|
1617
|
+
// data-driven columns resolved after a fetch — adds a `cell(...)` slot bvn
|
|
1618
|
+
// ignores, and the cell renders empty. Folding the dotted-cell field keys
|
|
1619
|
+
// into the signature remounts the inner table when that set changes, exactly
|
|
1620
|
+
// as it does for a consumer's late `cell(...)` slot.
|
|
1621
|
+
const consumerSlots = Object.keys(slots).filter(isTableSlot);
|
|
1622
|
+
const dottedCellSlots = dottedCellFields(slots).map((field) => `cell(${field.key})`);
|
|
1623
|
+
return [...consumerSlots, ...dottedCellSlots].sort().join('|');
|
|
1624
|
+
};
|
|
2068
1625
|
|
|
2069
1626
|
/*
|
|
2070
1627
|
* The template iterates `$slots` DIRECTLY rather than a computed over
|
|
@@ -2089,28 +1646,6 @@ const tablePassthroughProps = computed(() => ({
|
|
|
2089
1646
|
: `No ${pluralItemName.value} found`),
|
|
2090
1647
|
expandedItems: props.expandedItems,
|
|
2091
1648
|
}));
|
|
2092
|
-
const editFieldKeys = computed<string[]>(() =>
|
|
2093
|
-
(props.editFields ?? []).map((field: any) => field.key),
|
|
2094
|
-
);
|
|
2095
|
-
const tabKeys = computed<string[]>(() =>
|
|
2096
|
-
(props.editTabs ?? []).map((tab) => tab.key),
|
|
2097
|
-
);
|
|
2098
|
-
const editValueSlotKeys = computed(() =>
|
|
2099
|
-
editFieldKeys.value.filter((key) => !!tableSlots[`edit-value(${key})`]),
|
|
2100
|
-
);
|
|
2101
|
-
const editSpanSlotKeys = computed(() =>
|
|
2102
|
-
editFieldKeys.value.filter((key) => !!tableSlots[`edit-span(${key})`]),
|
|
2103
|
-
);
|
|
2104
|
-
const tabContentSlotKeys = computed(() =>
|
|
2105
|
-
tabKeys.value.filter((key) => !!tableSlots[`tab-content(${key})`]),
|
|
2106
|
-
);
|
|
2107
|
-
const tabBeforeSlotKeys = computed(() =>
|
|
2108
|
-
tabKeys.value.filter((key) => !!tableSlots[`tab-before(${key})`]),
|
|
2109
|
-
);
|
|
2110
|
-
const tabAfterSlotKeys = computed(() =>
|
|
2111
|
-
tabKeys.value.filter((key) => !!tableSlots[`tab-after(${key})`]),
|
|
2112
|
-
);
|
|
2113
|
-
|
|
2114
1649
|
// Computed: Singular and plural item names
|
|
2115
1650
|
const singularItemName = computed(() => props.itemName);
|
|
2116
1651
|
const pluralItemName = computed(() => pluralize(props.itemName));
|
|
@@ -2120,21 +1655,49 @@ const pluralItemName = computed(() => pluralize(props.itemName));
|
|
|
2120
1655
|
// only had a header to host the button loses the empty header too.
|
|
2121
1656
|
const showsCreateButton = computed(() => Boolean(props.createUrl) && props.showCreateButton);
|
|
2122
1657
|
|
|
2123
|
-
//
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
return `Edit ${singularItemName.value}`;
|
|
2133
|
-
}
|
|
2134
|
-
return typeof props.editModalTitle === 'function'
|
|
2135
|
-
? props.editModalTitle(selectedItem.value)
|
|
2136
|
-
: props.editModalTitle;
|
|
1658
|
+
// The create/edit/delete concern — modal state, form seeding, submission,
|
|
1659
|
+
// toasts, and the edit-modal slot-key computeds — lives in a composable (#129).
|
|
1660
|
+
// DXTable passes the editor props (structurally assignable), its `emit`, and the
|
|
1661
|
+
// one seam back into the table: a `refresh` callback run after a successful op.
|
|
1662
|
+
// The row→modal bridge stays in `handleRowClick` below (it emits `rowClicked`
|
|
1663
|
+
// regardless of whether the modal opens).
|
|
1664
|
+
const editor = useResourceEditor<T>(props, emit, {
|
|
1665
|
+
refresh,
|
|
1666
|
+
singularItemName,
|
|
2137
1667
|
});
|
|
1668
|
+
const {
|
|
1669
|
+
showEditModal,
|
|
1670
|
+
editForm,
|
|
1671
|
+
editFormInstanceKey,
|
|
1672
|
+
activeTabIndex,
|
|
1673
|
+
selectedItem,
|
|
1674
|
+
isCreateMode,
|
|
1675
|
+
pendingAction,
|
|
1676
|
+
editLoading,
|
|
1677
|
+
computedModalTitle,
|
|
1678
|
+
} = editor;
|
|
1679
|
+
|
|
1680
|
+
// Template-facing aliases so the modal buttons keep their historic names.
|
|
1681
|
+
const handleCreateNew = editor.openCreate;
|
|
1682
|
+
const handleEditSave = editor.save;
|
|
1683
|
+
const handleEditCancel = editor.cancel;
|
|
1684
|
+
const handleDelete = editor.remove;
|
|
1685
|
+
|
|
1686
|
+
// The consumer's edit-modal slots, forwarded verbatim to DXTableEditorModal
|
|
1687
|
+
// (which maps them onto DXForm). A FUNCTION over `$slots`, not a computed, for
|
|
1688
|
+
// the same reason as `forwardableSlotNames` — `useSlots()` is mutated in place
|
|
1689
|
+
// with nothing to track, so a computed captures the first render's set (#114).
|
|
1690
|
+
const EDIT_MODAL_SLOT_PREFIXES = [
|
|
1691
|
+
'edit-value(',
|
|
1692
|
+
'edit-span(',
|
|
1693
|
+
'tab-content(',
|
|
1694
|
+
'tab-before(',
|
|
1695
|
+
'tab-after(',
|
|
1696
|
+
];
|
|
1697
|
+
const editModalSlotNames = (slots: Record<string, unknown>): string[] =>
|
|
1698
|
+
Object.keys(slots).filter((name) =>
|
|
1699
|
+
EDIT_MODAL_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix)),
|
|
1700
|
+
);
|
|
2138
1701
|
|
|
2139
1702
|
// Helper: Get current sort state for a field
|
|
2140
1703
|
const getFieldSortState = (fieldKey: string) => {
|
|
@@ -2142,327 +1705,16 @@ const getFieldSortState = (fieldKey: string) => {
|
|
|
2142
1705
|
return currentSort?.order || null;
|
|
2143
1706
|
};
|
|
2144
1707
|
|
|
2145
|
-
// Handle row click
|
|
1708
|
+
// Handle row click. Emits `rowClicked` regardless (the seam consumers listen
|
|
1709
|
+
// on), then asks the editor to open — a no-op unless `editFields` are set.
|
|
2146
1710
|
const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
2147
1711
|
// A row the consumer marked non-actionable doesn't look clickable, so it
|
|
2148
1712
|
// must not BE clickable either — otherwise a click that looks dead quietly
|
|
2149
1713
|
// navigates or opens the modal (#115).
|
|
2150
1714
|
if (props.rowClickable && !props.rowClickable(item, index)) return;
|
|
2151
1715
|
|
|
2152
|
-
// Always emit rowClicked for custom handling
|
|
2153
1716
|
emit('rowClicked', item, index, event);
|
|
2154
|
-
|
|
2155
|
-
// If editFields provided, open edit modal
|
|
2156
|
-
if (props.editFields && props.editFields.length > 0) {
|
|
2157
|
-
// Set selected item FIRST before any rendering
|
|
2158
|
-
isCreateMode.value = false;
|
|
2159
|
-
selectedItem.value = item;
|
|
2160
|
-
|
|
2161
|
-
// Reset to first tab
|
|
2162
|
-
activeTabIndex.value = 0;
|
|
2163
|
-
|
|
2164
|
-
// Initialize form with item data. `useForm` is statically imported, so
|
|
2165
|
-
// seeding is synchronous — no interleaving between successive row opens.
|
|
2166
|
-
if (!editForm.value) {
|
|
2167
|
-
const formData: Record<string, any> = {};
|
|
2168
|
-
// Presentational fields (submit: false) lay the form out; they hold
|
|
2169
|
-
// no data and must not be POSTed just because they were declared.
|
|
2170
|
-
submittableEditFields.value.forEach(field => {
|
|
2171
|
-
formData[field.key] = seedValueFor(field, item);
|
|
2172
|
-
});
|
|
2173
|
-
editForm.value = useForm(formData);
|
|
2174
|
-
} else {
|
|
2175
|
-
// Update existing form
|
|
2176
|
-
submittableEditFields.value.forEach(field => {
|
|
2177
|
-
editForm.value.data[field.key] = seedValueFor(field, item);
|
|
2178
|
-
});
|
|
2179
|
-
editForm.value.clearErrors();
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
// Open modal
|
|
2183
|
-
editFormInstanceKey.value++;
|
|
2184
|
-
showEditModal.value = true;
|
|
2185
|
-
|
|
2186
|
-
// Optionally replace the row-seeded data with the full record.
|
|
2187
|
-
if (props.showUrl) void fetchFullRecordForEdit(item);
|
|
2188
|
-
}
|
|
2189
|
-
};
|
|
2190
|
-
|
|
2191
|
-
// Fetch the full record for the edit modal and re-seed the form from it. The
|
|
2192
|
-
// form is already showing row data; this fills in fields the list row omitted.
|
|
2193
|
-
const fetchFullRecordForEdit = async (item: T) => {
|
|
2194
|
-
const itemId = (item as any).id;
|
|
2195
|
-
if (itemId === undefined || itemId === null) return;
|
|
2196
|
-
|
|
2197
|
-
const token = ++editFetchToken;
|
|
2198
|
-
editLoading.value = true;
|
|
2199
|
-
try {
|
|
2200
|
-
const url = props.showUrl!.replace(':id', String(itemId));
|
|
2201
|
-
const response = await axios.get(url);
|
|
2202
|
-
// Superseded by a newer row-open — discard.
|
|
2203
|
-
if (token !== editFetchToken) return;
|
|
2204
|
-
|
|
2205
|
-
const record = response.data?.data ?? response.data;
|
|
2206
|
-
if (record && editForm.value) {
|
|
2207
|
-
submittableEditFields.value.forEach(field => {
|
|
2208
|
-
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
2209
|
-
editForm.value.data[field.key] = record[field.key];
|
|
2210
|
-
}
|
|
2211
|
-
});
|
|
2212
|
-
// Widen selectedItem so predicates / the delete guard see full data.
|
|
2213
|
-
selectedItem.value = { ...(selectedItem.value as any), ...record };
|
|
2214
|
-
}
|
|
2215
|
-
} catch (error: any) {
|
|
2216
|
-
if (token !== editFetchToken) return;
|
|
2217
|
-
const message =
|
|
2218
|
-
error?.response?.data?.message ??
|
|
2219
|
-
error?.message ??
|
|
2220
|
-
'Failed to load the full record.';
|
|
2221
|
-
createToast?.({
|
|
2222
|
-
title: 'Error',
|
|
2223
|
-
body: message,
|
|
2224
|
-
variant: 'danger',
|
|
2225
|
-
modelValue: 5000,
|
|
2226
|
-
});
|
|
2227
|
-
} finally {
|
|
2228
|
-
if (token === editFetchToken) editLoading.value = false;
|
|
2229
|
-
}
|
|
2230
|
-
};
|
|
2231
|
-
|
|
2232
|
-
// Handle "New" button click
|
|
2233
|
-
const handleCreateNew = () => {
|
|
2234
|
-
if (!props.editFields || props.editFields.length === 0) return;
|
|
2235
|
-
|
|
2236
|
-
isCreateMode.value = true;
|
|
2237
|
-
selectedItem.value = null;
|
|
2238
|
-
activeTabIndex.value = 0;
|
|
2239
|
-
|
|
2240
|
-
// Supersede any in-flight row-open fetch so a slow showUrl fetch from an
|
|
2241
|
-
// earlier edit can't seed over this create.
|
|
2242
|
-
editFetchToken++;
|
|
2243
|
-
editLoading.value = false;
|
|
2244
|
-
|
|
2245
|
-
if (!editForm.value) {
|
|
2246
|
-
const formData: Record<string, any> = {};
|
|
2247
|
-
submittableEditFields.value.forEach(field => {
|
|
2248
|
-
formData[field.key] = field.default ?? '';
|
|
2249
|
-
});
|
|
2250
|
-
editForm.value = useForm(formData);
|
|
2251
|
-
} else {
|
|
2252
|
-
// Reset existing form to defaults
|
|
2253
|
-
submittableEditFields.value.forEach(field => {
|
|
2254
|
-
editForm.value.data[field.key] = field.default ?? '';
|
|
2255
|
-
});
|
|
2256
|
-
editForm.value.clearErrors();
|
|
2257
|
-
}
|
|
2258
|
-
editFormInstanceKey.value++;
|
|
2259
|
-
showEditModal.value = true;
|
|
2260
|
-
};
|
|
2261
|
-
|
|
2262
|
-
// Handle save from edit modal
|
|
2263
|
-
const handleEditSave = async () => {
|
|
2264
|
-
if (!editForm.value) return;
|
|
2265
|
-
|
|
2266
|
-
pendingAction.value = 'save';
|
|
2267
|
-
try {
|
|
2268
|
-
await performSave();
|
|
2269
|
-
} finally {
|
|
2270
|
-
pendingAction.value = null;
|
|
2271
|
-
}
|
|
2272
|
-
};
|
|
2273
|
-
|
|
2274
|
-
const performSave = async () => {
|
|
2275
|
-
// Create mode: POST to createUrl
|
|
2276
|
-
if (isCreateMode.value && props.createUrl) {
|
|
2277
|
-
try {
|
|
2278
|
-
await editForm.value.post(props.createUrl, {
|
|
2279
|
-
transform: stripNonSubmittedFields,
|
|
2280
|
-
onSuccess: (data: any) => {
|
|
2281
|
-
createToast?.({
|
|
2282
|
-
title: 'Success',
|
|
2283
|
-
body: `${singularItemName.value} created successfully`,
|
|
2284
|
-
variant: 'success',
|
|
2285
|
-
modelValue: 3000,
|
|
2286
|
-
});
|
|
2287
|
-
|
|
2288
|
-
emit('rowCreated', data?.data ?? data, data);
|
|
2289
|
-
showEditModal.value = false;
|
|
2290
|
-
selectedItem.value = null;
|
|
2291
|
-
isCreateMode.value = false;
|
|
2292
|
-
|
|
2293
|
-
refresh();
|
|
2294
|
-
},
|
|
2295
|
-
onError: (errors: any) => {
|
|
2296
|
-
let errorMessage = 'Failed to create. Please check the form for errors.';
|
|
2297
|
-
if (errors && typeof errors === 'object') {
|
|
2298
|
-
const firstError = Object.values(errors).flat()[0];
|
|
2299
|
-
if (typeof firstError === 'string') {
|
|
2300
|
-
errorMessage = firstError;
|
|
2301
|
-
}
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
|
-
createToast?.({
|
|
2305
|
-
title: 'Error',
|
|
2306
|
-
body: errorMessage,
|
|
2307
|
-
variant: 'danger',
|
|
2308
|
-
modelValue: 5000,
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
// DXForm switches to the first errored tab via its
|
|
2312
|
-
// own watcher on editForm.errors.
|
|
2313
|
-
emit('createError', errors);
|
|
2314
|
-
}
|
|
2315
|
-
});
|
|
2316
|
-
} catch (error) {
|
|
2317
|
-
emit('createError', error);
|
|
2318
|
-
}
|
|
2319
|
-
return;
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
// Edit mode: PUT to editUrl
|
|
2323
|
-
if (!selectedItem.value) return;
|
|
2324
|
-
|
|
2325
|
-
try {
|
|
2326
|
-
// If editUrl provided, handle API call internally
|
|
2327
|
-
if (props.editUrl) {
|
|
2328
|
-
const itemId = (selectedItem.value as any).id;
|
|
2329
|
-
const url = props.editUrl.replace(':id', itemId);
|
|
2330
|
-
|
|
2331
|
-
await editForm.value.put(url, {
|
|
2332
|
-
transform: stripNonSubmittedFields,
|
|
2333
|
-
onSuccess: (data: any) => {
|
|
2334
|
-
// Show success toast
|
|
2335
|
-
createToast?.({
|
|
2336
|
-
title: 'Success',
|
|
2337
|
-
body: `${singularItemName.value} updated successfully`,
|
|
2338
|
-
variant: 'success',
|
|
2339
|
-
modelValue: 3000, // Auto-dismiss after 3 seconds
|
|
2340
|
-
});
|
|
2341
|
-
|
|
2342
|
-
emit('rowUpdated', selectedItem.value as T, data);
|
|
2343
|
-
showEditModal.value = false;
|
|
2344
|
-
selectedItem.value = null;
|
|
2345
|
-
|
|
2346
|
-
// Refresh table data to show updated values
|
|
2347
|
-
refresh();
|
|
2348
|
-
},
|
|
2349
|
-
onError: (errors: any) => {
|
|
2350
|
-
// Extract first error message for toast
|
|
2351
|
-
let errorMessage = 'Failed to update. Please check the form for errors.';
|
|
2352
|
-
if (errors && typeof errors === 'object') {
|
|
2353
|
-
const firstError = Object.values(errors).flat()[0];
|
|
2354
|
-
if (typeof firstError === 'string') {
|
|
2355
|
-
errorMessage = firstError;
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// Show error toast with specific message
|
|
2360
|
-
createToast?.({
|
|
2361
|
-
title: 'Error',
|
|
2362
|
-
body: errorMessage,
|
|
2363
|
-
variant: 'danger',
|
|
2364
|
-
modelValue: 5000, // Auto-dismiss after 5 seconds
|
|
2365
|
-
});
|
|
2366
|
-
|
|
2367
|
-
// DXForm switches to the first errored tab via its
|
|
2368
|
-
// own watcher on editForm.errors.
|
|
2369
|
-
emit('editError', selectedItem.value as T, errors);
|
|
2370
|
-
}
|
|
2371
|
-
});
|
|
2372
|
-
} else {
|
|
2373
|
-
// No editUrl - just emit event for custom handling
|
|
2374
|
-
emit('rowUpdated', selectedItem.value as T, editForm.value.data);
|
|
2375
|
-
showEditModal.value = false;
|
|
2376
|
-
selectedItem.value = null;
|
|
2377
|
-
}
|
|
2378
|
-
} catch (error) {
|
|
2379
|
-
emit('editError', selectedItem.value as T, error);
|
|
2380
|
-
}
|
|
2381
|
-
};
|
|
2382
|
-
|
|
2383
|
-
// Handle edit modal close
|
|
2384
|
-
const handleEditCancel = () => {
|
|
2385
|
-
showEditModal.value = false;
|
|
2386
|
-
selectedItem.value = null;
|
|
2387
|
-
isCreateMode.value = false;
|
|
2388
|
-
activeTabIndex.value = 0; // Reset tab for next time
|
|
2389
|
-
// Abandon any in-flight showUrl fetch so it can't seed a closed modal.
|
|
2390
|
-
editFetchToken++;
|
|
2391
|
-
editLoading.value = false;
|
|
2392
|
-
if (editForm.value) {
|
|
2393
|
-
editForm.value.clearErrors();
|
|
2394
|
-
}
|
|
2395
|
-
};
|
|
2396
|
-
|
|
2397
|
-
// Handle delete from edit modal
|
|
2398
|
-
const handleDelete = async () => {
|
|
2399
|
-
if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
|
|
2400
|
-
// Don't evaluate the guard until the full record has loaded (showUrl) — it
|
|
2401
|
-
// may depend on fields the thin list row doesn't carry.
|
|
2402
|
-
if (editLoading.value) return;
|
|
2403
|
-
|
|
2404
|
-
// Delete guard: a non-null message means this item can't be deleted — show
|
|
2405
|
-
// it immediately and skip the confirm and the request entirely.
|
|
2406
|
-
const guardMessage = props.deleteGuard?.(selectedItem.value as T);
|
|
2407
|
-
if (guardMessage) {
|
|
2408
|
-
createToast?.({
|
|
2409
|
-
title: 'Cannot delete',
|
|
2410
|
-
body: guardMessage,
|
|
2411
|
-
variant: 'danger',
|
|
2412
|
-
modelValue: 5000,
|
|
2413
|
-
});
|
|
2414
|
-
return;
|
|
2415
|
-
}
|
|
2416
|
-
|
|
2417
|
-
// Confirm deletion
|
|
2418
|
-
const itemName = (selectedItem.value as any).name || (selectedItem.value as any).title || singularItemName.value;
|
|
2419
|
-
const confirmed = window.confirm(`Are you sure you want to delete "${itemName}"? This action cannot be undone.`);
|
|
2420
|
-
|
|
2421
|
-
if (!confirmed) return;
|
|
2422
|
-
|
|
2423
|
-
pendingAction.value = 'delete';
|
|
2424
|
-
try {
|
|
2425
|
-
const itemId = (selectedItem.value as any).id;
|
|
2426
|
-
const url = props.deleteUrl.replace(':id', itemId);
|
|
2427
|
-
|
|
2428
|
-
await editForm.value.delete(url, {
|
|
2429
|
-
onSuccess: (data: any) => {
|
|
2430
|
-
// Show success toast
|
|
2431
|
-
createToast?.({
|
|
2432
|
-
title: 'Success',
|
|
2433
|
-
body: `${singularItemName.value} deleted successfully`,
|
|
2434
|
-
variant: 'success',
|
|
2435
|
-
modelValue: 3000, // Auto-dismiss after 3 seconds
|
|
2436
|
-
});
|
|
2437
|
-
|
|
2438
|
-
emit('rowDeleted', selectedItem.value as T, data);
|
|
2439
|
-
showEditModal.value = false;
|
|
2440
|
-
selectedItem.value = null;
|
|
2441
|
-
|
|
2442
|
-
// Refresh table data to remove deleted item
|
|
2443
|
-
refresh();
|
|
2444
|
-
},
|
|
2445
|
-
onError: (error: any) => {
|
|
2446
|
-
// Extract error message from server response
|
|
2447
|
-
const errorData = error?.response?.data ?? error?.data ?? error;
|
|
2448
|
-
const errorMessage = errorData?.message ?? 'Failed to delete. Please try again.';
|
|
2449
|
-
|
|
2450
|
-
// Show error toast with server message
|
|
2451
|
-
createToast?.({
|
|
2452
|
-
title: 'Error',
|
|
2453
|
-
body: errorMessage,
|
|
2454
|
-
variant: 'danger',
|
|
2455
|
-
modelValue: 5000, // Auto-dismiss after 5 seconds
|
|
2456
|
-
});
|
|
2457
|
-
|
|
2458
|
-
emit('deleteError', selectedItem.value as T, error);
|
|
2459
|
-
}
|
|
2460
|
-
});
|
|
2461
|
-
} catch (error) {
|
|
2462
|
-
emit('deleteError', selectedItem.value as T, error);
|
|
2463
|
-
} finally {
|
|
2464
|
-
pendingAction.value = null;
|
|
2465
|
-
}
|
|
1717
|
+
editor.openEdit(item);
|
|
2466
1718
|
};
|
|
2467
1719
|
|
|
2468
1720
|
defineExpose({
|
|
@@ -2487,21 +1739,4 @@ defineExpose({
|
|
|
2487
1739
|
:deep(tbody tr.dx-row-actionable:hover) {
|
|
2488
1740
|
background-color: var(--bs-table-hover-bg);
|
|
2489
1741
|
}
|
|
2490
|
-
|
|
2491
|
-
/* Improve pagination button sizing to match form controls */
|
|
2492
|
-
:deep(.pagination) {
|
|
2493
|
-
margin-bottom: 0;
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
:deep(.pagination-sm .page-link) {
|
|
2497
|
-
min-width: 2.25rem;
|
|
2498
|
-
height: auto;
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
/* Make disabled pagination buttons more subtle */
|
|
2502
|
-
:deep(.pagination .page-item.disabled .page-link) {
|
|
2503
|
-
background-color: transparent;
|
|
2504
|
-
border-color: transparent;
|
|
2505
|
-
opacity: 0.3;
|
|
2506
|
-
}
|
|
2507
1742
|
</style>
|