@omnitend/dashboard-for-laravel 0.27.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.
@@ -40,24 +40,25 @@
40
40
  <p class="mt-2">{{ loadingText }}</p>
41
41
  </div>
42
42
 
43
- <!-- Provider Mode: Use BTable's provider pattern -->
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"
@@ -164,552 +165,73 @@
164
165
  </template>
165
166
  </DTable>
166
167
 
167
- <!-- Client-Side Mode: Local filtering, sorting, pagination -->
168
- <DTable
169
- v-else-if="isClientSideMode"
170
- :key="tableSlotSignature($slots)"
171
- :items="clientSidePaginatedItems"
172
- :fields="fields"
173
- :sort-by="effectiveSortBy"
174
- :multisort="false"
175
- :no-local-sorting="true"
176
- :no-sortable-icon="true"
177
- :striped="striped"
178
- :hover="hover"
179
- :responsive="responsive"
180
- :tbody-tr-class="composeRowClass"
181
- v-bind="tablePassthroughProps"
182
- @update:sort-by="handleSortChange"
183
- @update:expanded-items="emit('update:expandedItems', $event)"
184
- @row-clicked="handleRowClick"
185
- >
186
- <!-- DXTable owns `thead-top` (its filter row lives there), so a
187
- consumer's own thead-top content is COMPOSED above it rather
188
- than being dropped: a grouped column-header banner or a pinned
189
- totals row sits above the headers where it belongs (#120). -->
190
- <template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
191
- <!--
192
- @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.
193
- @binding {object} columns The number of columns in the table.
194
- @binding {object} fields The table's fields.
195
- -->
196
- <slot name="thead-top" v-bind="theadScope" />
197
-
198
- <tr v-if="hasFilters" class="filter-row">
199
- <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
200
- <!-- Text Filter -->
201
- <DFormInput
202
- v-if="field.filter === 'text'"
203
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
204
- :placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
205
- size="sm"
206
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
207
- />
208
-
209
- <!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
210
- <DAutocomplete
211
- v-else-if="field.filter === 'select'"
212
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
213
- :options="getFieldFilterOptions(field)"
214
- :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
215
- size="sm"
216
- open-on-focus
217
- @update:model-value="handleSelectFilterChange(field, $event)"
218
- />
219
-
220
- <!-- Number Filter -->
221
- <DFormInput
222
- v-else-if="field.filter === 'number'"
223
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
224
- :placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
225
- type="number"
226
- size="sm"
227
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
228
- />
229
-
230
- <!-- Date Filter -->
231
- <DFormInput
232
- v-else-if="field.filter === 'date'"
233
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
234
- type="date"
235
- size="sm"
236
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
237
- />
238
-
239
- <!-- No filter for this column -->
240
- <div v-else></div>
241
- </th>
242
- </tr>
243
- </template>
244
-
245
- <!-- Custom headers for all fields -->
246
- <template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
247
- <div class="d-flex align-items-center justify-content-between gap-2">
248
- <div class="flex-grow-1">
249
- <div class="fw-semibold">{{ headerLabel(field, label) }}</div>
250
- <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
251
- </div>
252
- <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;">
253
- <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
254
- <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
255
- </div>
256
- </div>
257
- </template>
258
-
259
- <!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
260
- bootstrap-vue-next, in every payload shape, so DXTable resolves
261
- those columns itself (#121). A consumer's own cell(<key>) slot
262
- wins — `dottedCellFields` excludes it, and the forwarding below
263
- would otherwise declare the same slot name twice. -->
264
- <template
265
- v-for="field in dottedCellFields($slots)"
266
- :key="`dotted-cell-${field.key}`"
267
- #[`cell(${field.key})`]="{ item }"
268
- >{{ fieldValueOf(item, field.key) }}</template>
269
-
270
- <!-- Forward every slot the inner table understands: cell(<key>),
271
- foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
272
- table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
273
- See `isTableSlot` for the two DXTable renders itself. -->
274
- <template
275
- v-for="name in forwardableSlotNames($slots)"
276
- :key="name"
277
- #[name]="slotProps"
278
- >
279
- <!--
280
- @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.
281
- -->
282
- <slot :name="name" v-bind="slotProps" />
283
- </template>
284
- </DTable>
285
-
286
- <!-- Inertia Mode: Use items prop -->
287
- <DTable
288
- v-else-if="isInertiaMode"
289
- :key="tableSlotSignature($slots)"
290
- :items="items"
291
- :fields="fields"
292
- :sort-by="effectiveSortBy"
293
- :multisort="false"
294
- :no-local-sorting="true"
295
- :no-sortable-icon="true"
296
- :striped="striped"
297
- :hover="hover"
298
- :responsive="responsive"
299
- :busy="effectiveBusy"
300
- :tbody-tr-class="composeRowClass"
301
- v-bind="tablePassthroughProps"
302
- @update:sort-by="handleSortChange"
303
- @update:expanded-items="emit('update:expandedItems', $event)"
304
- @row-clicked="handleRowClick"
305
- >
306
- <!-- DXTable owns `thead-top` (its filter row lives there), so a
307
- consumer's own thead-top content is COMPOSED above it rather
308
- than being dropped: a grouped column-header banner or a pinned
309
- totals row sits above the headers where it belongs (#120). -->
310
- <template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
311
- <!--
312
- @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.
313
- @binding {object} columns The number of columns in the table.
314
- @binding {object} fields The table's fields.
315
- -->
316
- <slot name="thead-top" v-bind="theadScope" />
317
-
318
- <tr v-if="hasFilters" class="filter-row">
319
- <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
320
- <!-- Text Filter -->
321
- <DFormInput
322
- v-if="field.filter === 'text'"
323
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
324
- :placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
325
- size="sm"
326
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
327
- />
328
-
329
- <!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
330
- <DAutocomplete
331
- v-else-if="field.filter === 'select'"
332
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
333
- :options="getFieldFilterOptions(field)"
334
- :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
335
- size="sm"
336
- open-on-focus
337
- @update:model-value="handleSelectFilterChange(field, $event)"
338
- />
339
-
340
- <!-- Number Filter -->
341
- <DFormInput
342
- v-else-if="field.filter === 'number'"
343
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
344
- :placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
345
- type="number"
346
- size="sm"
347
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
348
- />
349
-
350
- <!-- Date Filter -->
351
- <DFormInput
352
- v-else-if="field.filter === 'date'"
353
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
354
- type="date"
355
- size="sm"
356
- @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
357
- />
358
-
359
- <!-- No filter for this column -->
360
- <div v-else></div>
361
- </th>
362
- </tr>
363
- </template>
364
-
365
- <!-- Custom headers for all fields -->
366
- <template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
367
- <div class="d-flex align-items-center justify-content-between gap-2">
368
- <div class="flex-grow-1">
369
- <div class="fw-semibold">{{ headerLabel(field, label) }}</div>
370
- <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
371
- </div>
372
- <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;">
373
- <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
374
- <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
375
- </div>
376
- </div>
377
- </template>
378
-
379
- <!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
380
- bootstrap-vue-next, in every payload shape, so DXTable resolves
381
- those columns itself (#121). A consumer's own cell(<key>) slot
382
- wins — `dottedCellFields` excludes it, and the forwarding below
383
- would otherwise declare the same slot name twice. -->
384
- <template
385
- v-for="field in dottedCellFields($slots)"
386
- :key="`dotted-cell-${field.key}`"
387
- #[`cell(${field.key})`]="{ item }"
388
- >{{ fieldValueOf(item, field.key) }}</template>
389
-
390
- <!-- Forward every slot the inner table understands: cell(<key>),
391
- foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
392
- table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
393
- See `isTableSlot` for the two DXTable renders itself. -->
394
- <template
395
- v-for="name in forwardableSlotNames($slots)"
396
- :key="name"
397
- #[name]="slotProps"
398
- >
399
- <!--
400
- @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.
401
- -->
402
- <slot :name="name" v-bind="slotProps" />
403
- </template>
404
- </DTable>
405
-
406
- <!-- Pagination and Controls (Client-Side mode) -->
407
- <div v-if="isClientSideMode" class="mt-3">
408
- <!-- Top row: Pagination and Per-page selector -->
409
- <div class="d-flex justify-content-between align-items-center mb-2">
410
- <!-- Pagination controls (only when multiple pages) -->
411
- <DPagination
412
- v-if="showPagination && clientSidePagination.total > clientSidePagination.per_page"
413
- :model-value="clientSidePagination.current_page"
414
- :total-rows="clientSidePagination.total"
415
- :per-page="clientSidePagination.per_page"
416
- size="sm"
417
- @update:model-value="handleClientSidePageChange"
418
- />
419
- <div v-else></div>
420
-
421
- <!-- Per-page selector -->
422
- <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
423
- <label for="perPageSelectClientSide" class="mb-0 small text-muted">Per page</label>
424
- <DFormSelect
425
- id="perPageSelectClientSide"
426
- :model-value="effectivePerPage"
427
- :options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
428
- size="sm"
429
- style="width: 85px;"
430
- @update:model-value="handlePerPageChange"
431
- />
432
- </div>
433
- </div>
434
-
435
- <!-- Bottom row: Info text -->
436
- <div class="small text-muted">
437
- <div>
438
- <template v-if="clientSidePagination.total > clientSidePagination.per_page">
439
- {{ clientSidePagination.from }} to {{ clientSidePagination.to }} out of {{ clientSidePagination.total }} {{ clientSidePagination.total === 1 ? singularItemName : pluralItemName }}.
440
- </template>
441
- <template v-else-if="clientSidePagination.total === 1">
442
- {{ clientSidePagination.total }} {{ singularItemName }}.
443
- </template>
444
- <template v-else>
445
- {{ clientSidePagination.total }} {{ pluralItemName }}.
446
- </template>
447
- </div>
448
- <div v-if="hasActiveFilters && clientSidePagination.total_unfiltered">
449
- <small>Filtered from {{ clientSidePagination.total_unfiltered }} {{ clientSidePagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
450
- </div>
451
- </div>
452
- </div>
453
-
454
- <!-- Pagination and Controls (Inertia mode) -->
455
- <div v-if="isInertiaMode && pagination" class="mt-3">
456
- <!-- Top row: Pagination and Per-page selector -->
457
- <div class="d-flex justify-content-between align-items-center mb-2">
458
- <!-- Pagination controls (only when multiple pages) -->
459
- <DPagination
460
- v-if="showPagination && pagination.total > pagination.per_page"
461
- :model-value="pagination.current_page"
462
- :total-rows="pagination.total"
463
- :per-page="pagination.per_page"
464
- size="sm"
465
- @update:model-value="handlePageChange"
466
- />
467
- <div v-else></div>
468
-
469
- <!-- Per-page selector -->
470
- <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
471
- <label for="perPageSelect" class="mb-0 small text-muted">Per page</label>
472
- <DFormSelect
473
- id="perPageSelect"
474
- :model-value="effectivePerPage"
475
- :options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
476
- size="sm"
477
- style="width: 85px;"
478
- @update:model-value="handlePerPageChange"
479
- />
480
- </div>
481
- </div>
482
-
483
- <!-- Bottom row: Info text -->
484
- <div class="small text-muted">
485
- <div>
486
- <template v-if="pagination.total > pagination.per_page">
487
- {{ pagination.from }} to {{ pagination.to }} out of {{ pagination.total }} {{ pagination.total === 1 ? singularItemName : pluralItemName }}.
488
- </template>
489
- <template v-else-if="pagination.total === 1">
490
- {{ pagination.total }} {{ singularItemName }}.
491
- </template>
492
- <template v-else>
493
- {{ pagination.total }} {{ pluralItemName }}.
494
- </template>
495
- </div>
496
- <div v-if="hasActiveFilters && pagination.total_unfiltered">
497
- <small>Filtered from {{ pagination.total_unfiltered }} {{ pagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
498
- </div>
499
- </div>
500
- </div>
501
-
502
- <!-- Pagination and Controls (API mode) -->
503
- <div v-if="isProviderMode && providerPagination" class="mt-3">
504
- <!-- Top row: Pagination and Per-page selector -->
505
- <div class="d-flex justify-content-between align-items-center mb-2">
506
- <!-- Pagination controls (only when multiple pages) -->
507
- <DPagination
508
- v-if="showPagination && providerPagination.total > providerPagination.per_page"
509
- :model-value="providerPagination.current_page"
510
- :total-rows="providerPagination.total"
511
- :per-page="providerPagination.per_page"
512
- size="sm"
513
- @update:model-value="handleApiPageChange"
514
- />
515
- <div v-else></div>
516
-
517
- <!-- Per-page selector -->
518
- <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
519
- <label for="perPageSelectApi" class="mb-0 small text-muted">Per page</label>
520
- <DFormSelect
521
- id="perPageSelectApi"
522
- :model-value="effectivePerPage"
523
- :options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
524
- size="sm"
525
- style="width: 85px;"
526
- @update:model-value="handlePerPageChange"
527
- />
528
- </div>
529
- </div>
530
-
531
- <!-- Bottom row: Info text -->
532
- <div class="small text-muted">
533
- <div>
534
- <template v-if="providerPagination.total > providerPagination.per_page">
535
- {{ providerPagination.from }} to {{ providerPagination.to }} out of {{ providerPagination.total }} {{ providerPagination.total === 1 ? singularItemName : pluralItemName }}.
536
- </template>
537
- <template v-else-if="providerPagination.total === 1">
538
- {{ providerPagination.total }} {{ singularItemName }}.
539
- </template>
540
- <template v-else>
541
- {{ providerPagination.total }} {{ pluralItemName }}.
542
- </template>
543
- </div>
544
- <div v-if="hasActiveFilters && providerPagination.total_unfiltered">
545
- <small>Filtered from {{ providerPagination.total_unfiltered }} {{ providerPagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
546
- </div>
547
- </div>
548
- </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
+ />
549
186
  </DXTableShell>
550
187
  </DCol>
551
188
  </DRow>
552
189
 
553
- <!-- Edit Modal (if editFields provided) -->
554
- <DModal
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
555
195
  v-if="editFields && editFields.length > 0"
556
- v-model="showEditModal"
196
+ v-model:show="showEditModal"
197
+ v-model:active-tab="activeTabIndex"
557
198
  :title="computedModalTitle"
558
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"
559
212
  >
560
- <!-- Loading the full record for edit (showUrl fetch) -->
561
- <div
562
- v-if="editLoading"
563
- class="dx-edit-loading d-flex align-items-center gap-2 text-muted mb-3"
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"
564
222
  >
565
- <DSpinner small />
566
- <span>Loading…</span>
567
- </div>
568
-
569
- <!-- Edit/create form (tabbed when editTabs provided, flat otherwise).
570
-
571
- Keyed per modal open: the form OBJECT is created once and then
572
- reseeded in place for each row, so without a key Vue reuses the
573
- same DXField instances across records — and any per-field UI
574
- state rides along with them. That leaked a revealed password
575
- from one row into the next. The key gives each record a fresh
576
- field subtree; the data still lives in `editForm`, so nothing
577
- is lost by remounting. -->
578
- <DXForm
579
- v-if="editForm"
580
- :key="editFormInstanceKey"
581
- v-model:active-tab="activeTabIndex"
582
- :form="editForm"
583
- :fields="editFields"
584
- :tabs="editTabs"
585
- :context="selectedItem ?? undefined"
586
- :show-submit="false"
587
- @submit="handleEditSave"
588
- >
589
- <!-- Forward DXTable's edit-value(key) → DXForm value(key) -->
590
- <template
591
- v-for="key in editValueSlotKeys"
592
- :key="`ev-${key}`"
593
- #[`value(${key})`]="sp"
594
- >
595
- <!--
596
- @slot Custom input for field `<key>` in the edit/create modal, forwarded to DXForm. Name it `edit-value(<fieldKey>)`.
597
- @binding {object} item The row being edited (null in create mode).
598
- @binding {any} value The current field value.
599
- @binding {Function} update Call with a new value to update the field.
600
- @binding {object} field The field definition.
601
- -->
602
- <slot
603
- :name="`edit-value(${key})`"
604
- :item="selectedItem"
605
- :value="sp.value"
606
- :update="sp.update"
607
- :field="sp.field"
608
- />
609
- </template>
610
-
611
- <!-- Forward edit-span(key) → span(key) -->
612
- <template
613
- v-for="key in editSpanSlotKeys"
614
- :key="`es-${key}`"
615
- #[`span(${key})`]="sp"
616
- >
617
- <!--
618
- @slot Full-width custom content for field `<key>` in the edit/create modal, forwarded to DXForm's span slot. Name it `edit-span(<fieldKey>)`.
619
- @binding {object} item The row being edited (null in create mode).
620
- @binding {any} value The current field value.
621
- @binding {Function} update Call with a new value to update the field.
622
- @binding {Function} close Call to close the edit modal.
623
- -->
624
- <slot
625
- :name="`edit-span(${key})`"
626
- :item="selectedItem"
627
- :value="sp.value"
628
- :update="sp.update"
629
- :close="handleEditCancel"
630
- />
631
- </template>
632
-
633
- <!-- Forward tab-content / tab-before / tab-after slots -->
634
- <template
635
- v-for="key in tabContentSlotKeys"
636
- :key="`tc-${key}`"
637
- #[`tab-content(${key})`]="sp"
638
- >
639
- <!--
640
- @slot Replaces the auto-rendered fields of edit-modal tab `<key>` with custom content. Name it `tab-content(<tabKey>)`.
641
- @binding {object} item The row being edited (null in create mode).
642
- @binding {object} tab The tab definition.
643
- -->
644
- <slot :name="`tab-content(${key})`" :item="selectedItem" :tab="sp.tab" />
645
- </template>
646
- <template
647
- v-for="key in tabBeforeSlotKeys"
648
- :key="`tb-${key}`"
649
- #[`tab-before(${key})`]="sp"
650
- >
651
- <!--
652
- @slot Custom content rendered before the fields of edit-modal tab `<key>`. Name it `tab-before(<tabKey>)`.
653
- @binding {object} item The row being edited (null in create mode).
654
- @binding {object} tab The tab definition.
655
- -->
656
- <slot :name="`tab-before(${key})`" :item="selectedItem" :tab="sp.tab" />
657
- </template>
658
- <template
659
- v-for="key in tabAfterSlotKeys"
660
- :key="`taf-${key}`"
661
- #[`tab-after(${key})`]="sp"
662
- >
663
- <!--
664
- @slot Custom content rendered after the fields of edit-modal tab `<key>`. Name it `tab-after(<tabKey>)`.
665
- @binding {object} item The row being edited (null in create mode).
666
- @binding {object} tab The tab definition.
667
- -->
668
- <slot :name="`tab-after(${key})`" :item="selectedItem" :tab="sp.tab" />
669
- </template>
670
- </DXForm>
671
-
672
- <template #footer>
673
- <div class="d-flex justify-content-between w-100">
674
- <div>
675
- <DButton
676
- v-if="deleteUrl && !isCreateMode"
677
- variant="danger"
678
- :loading="pendingAction === 'delete'"
679
- loading-text="Deleting..."
680
- :disabled="editForm?.processing || editLoading"
681
- @click="handleDelete"
682
- >
683
- Delete
684
- </DButton>
685
- </div>
686
- <div class="d-flex gap-2">
687
- <DButton variant="secondary" @click="handleEditCancel">
688
- Cancel
689
- </DButton>
690
- <DButton
691
- variant="primary"
692
- :loading="pendingAction === 'save'"
693
- :loading-text="isCreateMode ? 'Creating...' : 'Saving...'"
694
- :disabled="editForm?.processing || editLoading"
695
- @click="handleEditSave"
696
- >
697
- {{ isCreateMode ? 'Create' : 'Save Changes' }}
698
- </DButton>
699
- </div>
700
- </div>
223
+ <slot :name="name" v-bind="slotProps" />
701
224
  </template>
702
- </DModal>
225
+ </DXTableEditorModal>
703
226
  </DContainer>
704
227
  </template>
705
228
 
706
229
  <script setup lang="ts" generic="T = any">
707
- import { computed, getCurrentInstance, ref, watch, useSlots } from "vue";
230
+ import { computed, getCurrentInstance, ref, watch } from "vue";
708
231
  import { router } from "@inertiajs/vue3";
709
232
  import axios from "axios";
710
233
  import pluralize from "pluralize";
711
- import { useToast } from "../../composables/useToast";
712
- import { useForm } from "../../composables/useForm";
234
+ import { useResourceEditor } from "../../composables/useResourceEditor";
713
235
  import { getByPath } from "../../utils/objectPath";
714
236
  import DContainer from "../base/DContainer.vue";
715
237
  import DRow from "../base/DRow.vue";
@@ -717,13 +239,11 @@ import DCol from "../base/DCol.vue";
717
239
  import DXTableShell from "./DXTableShell.vue";
718
240
  import DSpinner from "../base/DSpinner.vue";
719
241
  import DTable from "../base/DTable.vue";
720
- import DPagination from "../base/DPagination.vue";
721
242
  import DFormInput from "../base/DFormInput.vue";
722
- import DFormSelect from "../base/DFormSelect.vue";
723
243
  import DAutocomplete from "../base/DAutocomplete.vue";
724
- import DModal from "../base/DModal.vue";
725
244
  import DButton from "../base/DButton.vue";
726
- import DXForm from "./DXForm.vue";
245
+ import DXTablePagination from "./DXTablePagination.vue";
246
+ import DXTableEditorModal from "./DXTableEditorModal.vue";
727
247
  export type FilterType = 'text' | 'select' | 'number' | 'date' | false;
728
248
 
729
249
  export interface FilterOption {
@@ -1799,6 +1319,62 @@ const handleApiPageChange = (page: number) => {
1799
1319
  // BTable should automatically call provider when currentPage prop changes
1800
1320
  };
1801
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
+
1802
1378
  const handleSortChange = (sortBy: BTableSortBy[]) => {
1803
1379
  // ENFORCE single-column sorting: keep only the last clicked column
1804
1380
  // BTable may send multiple columns despite multisort: false
@@ -1974,133 +1550,6 @@ const refresh = () => {
1974
1550
  }
1975
1551
  };
1976
1552
 
1977
- // Edit Modal State
1978
- const showEditModal = ref(false);
1979
-
1980
- // Bumped on every modal open (edit or create) to key the DXForm subtree, so
1981
- // each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
1982
- // which reseeds the SAME record — remounting there would discard edits the
1983
- // user has already made while the fetch was in flight.
1984
- const editFormInstanceKey = ref(0);
1985
- const selectedItem = ref<T | null>(null);
1986
- const editForm = ref<any>(null);
1987
- const activeTabIndex = ref(0);
1988
- const isCreateMode = ref(false);
1989
-
1990
- // Which modal action is in flight, so the Save and Delete buttons show their
1991
- // own loading label independently. `editForm.processing` is shared by every
1992
- // request the form makes, so it can't tell Save from Delete on its own.
1993
- const pendingAction = ref<'save' | 'delete' | null>(null);
1994
-
1995
- // True while the edit modal is fetching the full record via `showUrl`.
1996
- const editLoading = ref(false);
1997
-
1998
- // Monotonic token so a slow fetch for a previously-opened row can't overwrite
1999
- // the form after the user has since opened a different row.
2000
- let editFetchToken = 0;
2001
-
2002
- // Toast (may not be available in test environment)
2003
- let createToast: ((obj: any) => any) | undefined;
2004
- try {
2005
- const toast = useToast();
2006
- createToast = toast.create;
2007
- } catch (e) {
2008
- // BApp not available (test environment or missing setup)
2009
- createToast = undefined;
2010
- }
2011
-
2012
- // The edit/create form rendering is delegated to DXForm, which
2013
- // owns field/tab visibility, dynamic labels/hints, conditional fields,
2014
- // and auto-switching to the first tab with a validation error.
2015
-
2016
- // Forward only the keyed edit slots the consumer actually provided, so
2017
- // DXForm doesn't mistake an always-present (but empty) wrapper for
2018
- // a real custom-value override.
2019
- /*
2020
- * A field's own starting value, used to seed a CREATE form and as the fallback
2021
- * when a row lacks the key (#122).
2022
- *
2023
- * `field.default ?? ''` can't express a NULL default: a select whose "none"
2024
- * option is `value: null` — which is what the column stores — seeds '' instead,
2025
- * matches no option, and renders blank. Presence, not nullishness, decides here
2026
- * too, so the create and edit paths agree about the same field.
2027
- */
2028
- const defaultValueFor = (field: any): any =>
2029
- Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
2030
-
2031
- /*
2032
- * Seeding an EDIT form from a row (#117).
2033
- *
2034
- * `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
2035
- * from "the row's value IS null", so an explicitly-null column gets overwritten
2036
- * by the field's default — a value the user never saw, on a field they may not
2037
- * even be able to see. Presence, not nullishness, decides.
2038
- */
2039
- const seedValueFor = (field: any, row: T): any => {
2040
- if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
2041
- return (row as any)[field.key];
2042
- }
2043
- return defaultValueFor(field);
2044
- };
2045
-
2046
- /*
2047
- * Whether a field is currently VISIBLE, by the same rule DXForm renders by
2048
- * (`when` + the legacy `show`), against the same model (context + live data).
2049
- * Kept in step with DXForm.isFieldVisible.
2050
- */
2051
- const editPredicateModel = computed(() => ({
2052
- ...((selectedItem.value as any) ?? {}),
2053
- ...(editForm.value?.data ?? {}),
2054
- }));
2055
-
2056
- const isEditFieldVisible = (field: any): boolean => {
2057
- const when = field.when;
2058
- const whenOk =
2059
- when === undefined
2060
- ? true
2061
- : typeof when === 'function'
2062
- ? when(editPredicateModel.value)
2063
- : when;
2064
- const showOk = field.show ? field.show() : true;
2065
- return whenOk && showOk;
2066
- };
2067
-
2068
- // Fields whose value actually belongs in the payload. A presentational field
2069
- // (submit: false) renders but holds no data — see FieldDefinition.submit.
2070
- const submittableEditFields = computed(() =>
2071
- (props.editFields ?? []).filter((field) => field.submit !== false),
2072
- );
2073
-
2074
- // Enforced at SUBMIT, not just at seeding: the modal still renders every field,
2075
- // so a `submit: false` control — or a `span` slot calling `update` — can write
2076
- // its key back into the form data after seeding. Strip them on the way out.
2077
- const nonSubmittedFieldKeys = computed(
2078
- () =>
2079
- new Set(
2080
- (props.editFields ?? [])
2081
- .filter(
2082
- (field) =>
2083
- // Presentational: lays the form out, holds no data (#110).
2084
- field.submit === false ||
2085
- // Hidden by `when` at submit time (#117). Submitting a field
2086
- // the user cannot see is a silent write — and with `default`
2087
- // set it writes a value they never chose. Omitting the key
2088
- // leaves the stored value alone.
2089
- !isEditFieldVisible(field),
2090
- )
2091
- .map((field) => field.key),
2092
- ),
2093
- );
2094
-
2095
- const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
2096
- if (nonSubmittedFieldKeys.value.size === 0) return data;
2097
- return Object.fromEntries(
2098
- Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
2099
- );
2100
- };
2101
-
2102
- const tableSlots = useSlots();
2103
-
2104
1553
  /*
2105
1554
  * Slot forwarding to the inner table.
2106
1555
  *
@@ -2197,28 +1646,6 @@ const tablePassthroughProps = computed(() => ({
2197
1646
  : `No ${pluralItemName.value} found`),
2198
1647
  expandedItems: props.expandedItems,
2199
1648
  }));
2200
- const editFieldKeys = computed<string[]>(() =>
2201
- (props.editFields ?? []).map((field: any) => field.key),
2202
- );
2203
- const tabKeys = computed<string[]>(() =>
2204
- (props.editTabs ?? []).map((tab) => tab.key),
2205
- );
2206
- const editValueSlotKeys = computed(() =>
2207
- editFieldKeys.value.filter((key) => !!tableSlots[`edit-value(${key})`]),
2208
- );
2209
- const editSpanSlotKeys = computed(() =>
2210
- editFieldKeys.value.filter((key) => !!tableSlots[`edit-span(${key})`]),
2211
- );
2212
- const tabContentSlotKeys = computed(() =>
2213
- tabKeys.value.filter((key) => !!tableSlots[`tab-content(${key})`]),
2214
- );
2215
- const tabBeforeSlotKeys = computed(() =>
2216
- tabKeys.value.filter((key) => !!tableSlots[`tab-before(${key})`]),
2217
- );
2218
- const tabAfterSlotKeys = computed(() =>
2219
- tabKeys.value.filter((key) => !!tableSlots[`tab-after(${key})`]),
2220
- );
2221
-
2222
1649
  // Computed: Singular and plural item names
2223
1650
  const singularItemName = computed(() => props.itemName);
2224
1651
  const pluralItemName = computed(() => pluralize(props.itemName));
@@ -2228,21 +1655,49 @@ const pluralItemName = computed(() => pluralize(props.itemName));
2228
1655
  // only had a header to host the button loses the empty header too.
2229
1656
  const showsCreateButton = computed(() => Boolean(props.createUrl) && props.showCreateButton);
2230
1657
 
2231
- // Computed: Modal title (supports function)
2232
- const computedModalTitle = computed(() => {
2233
- if (isCreateMode.value) {
2234
- return `New ${singularItemName.value}`;
2235
- }
2236
- if (!selectedItem.value) {
2237
- return `Edit ${singularItemName.value}`;
2238
- }
2239
- if (!props.editModalTitle) {
2240
- return `Edit ${singularItemName.value}`;
2241
- }
2242
- return typeof props.editModalTitle === 'function'
2243
- ? props.editModalTitle(selectedItem.value)
2244
- : 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,
2245
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
+ );
2246
1701
 
2247
1702
  // Helper: Get current sort state for a field
2248
1703
  const getFieldSortState = (fieldKey: string) => {
@@ -2250,327 +1705,16 @@ const getFieldSortState = (fieldKey: string) => {
2250
1705
  return currentSort?.order || null;
2251
1706
  };
2252
1707
 
2253
- // Handle row click for editing
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.
2254
1710
  const handleRowClick = (item: T, index: number, event: MouseEvent) => {
2255
1711
  // A row the consumer marked non-actionable doesn't look clickable, so it
2256
1712
  // must not BE clickable either — otherwise a click that looks dead quietly
2257
1713
  // navigates or opens the modal (#115).
2258
1714
  if (props.rowClickable && !props.rowClickable(item, index)) return;
2259
1715
 
2260
- // Always emit rowClicked for custom handling
2261
1716
  emit('rowClicked', item, index, event);
2262
-
2263
- // If editFields provided, open edit modal
2264
- if (props.editFields && props.editFields.length > 0) {
2265
- // Set selected item FIRST before any rendering
2266
- isCreateMode.value = false;
2267
- selectedItem.value = item;
2268
-
2269
- // Reset to first tab
2270
- activeTabIndex.value = 0;
2271
-
2272
- // Initialize form with item data. `useForm` is statically imported, so
2273
- // seeding is synchronous — no interleaving between successive row opens.
2274
- if (!editForm.value) {
2275
- const formData: Record<string, any> = {};
2276
- // Presentational fields (submit: false) lay the form out; they hold
2277
- // no data and must not be POSTed just because they were declared.
2278
- submittableEditFields.value.forEach(field => {
2279
- formData[field.key] = seedValueFor(field, item);
2280
- });
2281
- editForm.value = useForm(formData);
2282
- } else {
2283
- // Update existing form
2284
- submittableEditFields.value.forEach(field => {
2285
- editForm.value.data[field.key] = seedValueFor(field, item);
2286
- });
2287
- editForm.value.clearErrors();
2288
- }
2289
-
2290
- // Open modal
2291
- editFormInstanceKey.value++;
2292
- showEditModal.value = true;
2293
-
2294
- // Optionally replace the row-seeded data with the full record.
2295
- if (props.showUrl) void fetchFullRecordForEdit(item);
2296
- }
2297
- };
2298
-
2299
- // Fetch the full record for the edit modal and re-seed the form from it. The
2300
- // form is already showing row data; this fills in fields the list row omitted.
2301
- const fetchFullRecordForEdit = async (item: T) => {
2302
- const itemId = (item as any).id;
2303
- if (itemId === undefined || itemId === null) return;
2304
-
2305
- const token = ++editFetchToken;
2306
- editLoading.value = true;
2307
- try {
2308
- const url = props.showUrl!.replace(':id', String(itemId));
2309
- const response = await axios.get(url);
2310
- // Superseded by a newer row-open — discard.
2311
- if (token !== editFetchToken) return;
2312
-
2313
- const record = response.data?.data ?? response.data;
2314
- if (record && editForm.value) {
2315
- submittableEditFields.value.forEach(field => {
2316
- if (Object.prototype.hasOwnProperty.call(record, field.key)) {
2317
- editForm.value.data[field.key] = record[field.key];
2318
- }
2319
- });
2320
- // Widen selectedItem so predicates / the delete guard see full data.
2321
- selectedItem.value = { ...(selectedItem.value as any), ...record };
2322
- }
2323
- } catch (error: any) {
2324
- if (token !== editFetchToken) return;
2325
- const message =
2326
- error?.response?.data?.message ??
2327
- error?.message ??
2328
- 'Failed to load the full record.';
2329
- createToast?.({
2330
- title: 'Error',
2331
- body: message,
2332
- variant: 'danger',
2333
- modelValue: 5000,
2334
- });
2335
- } finally {
2336
- if (token === editFetchToken) editLoading.value = false;
2337
- }
2338
- };
2339
-
2340
- // Handle "New" button click
2341
- const handleCreateNew = () => {
2342
- if (!props.editFields || props.editFields.length === 0) return;
2343
-
2344
- isCreateMode.value = true;
2345
- selectedItem.value = null;
2346
- activeTabIndex.value = 0;
2347
-
2348
- // Supersede any in-flight row-open fetch so a slow showUrl fetch from an
2349
- // earlier edit can't seed over this create.
2350
- editFetchToken++;
2351
- editLoading.value = false;
2352
-
2353
- if (!editForm.value) {
2354
- const formData: Record<string, any> = {};
2355
- submittableEditFields.value.forEach(field => {
2356
- formData[field.key] = defaultValueFor(field);
2357
- });
2358
- editForm.value = useForm(formData);
2359
- } else {
2360
- // Reset existing form to defaults
2361
- submittableEditFields.value.forEach(field => {
2362
- editForm.value.data[field.key] = defaultValueFor(field);
2363
- });
2364
- editForm.value.clearErrors();
2365
- }
2366
- editFormInstanceKey.value++;
2367
- showEditModal.value = true;
2368
- };
2369
-
2370
- // Handle save from edit modal
2371
- const handleEditSave = async () => {
2372
- if (!editForm.value) return;
2373
-
2374
- pendingAction.value = 'save';
2375
- try {
2376
- await performSave();
2377
- } finally {
2378
- pendingAction.value = null;
2379
- }
2380
- };
2381
-
2382
- const performSave = async () => {
2383
- // Create mode: POST to createUrl
2384
- if (isCreateMode.value && props.createUrl) {
2385
- try {
2386
- await editForm.value.post(props.createUrl, {
2387
- transform: stripNonSubmittedFields,
2388
- onSuccess: (data: any) => {
2389
- createToast?.({
2390
- title: 'Success',
2391
- body: `${singularItemName.value} created successfully`,
2392
- variant: 'success',
2393
- modelValue: 3000,
2394
- });
2395
-
2396
- emit('rowCreated', data?.data ?? data, data);
2397
- showEditModal.value = false;
2398
- selectedItem.value = null;
2399
- isCreateMode.value = false;
2400
-
2401
- refresh();
2402
- },
2403
- onError: (errors: any) => {
2404
- let errorMessage = 'Failed to create. Please check the form for errors.';
2405
- if (errors && typeof errors === 'object') {
2406
- const firstError = Object.values(errors).flat()[0];
2407
- if (typeof firstError === 'string') {
2408
- errorMessage = firstError;
2409
- }
2410
- }
2411
-
2412
- createToast?.({
2413
- title: 'Error',
2414
- body: errorMessage,
2415
- variant: 'danger',
2416
- modelValue: 5000,
2417
- });
2418
-
2419
- // DXForm switches to the first errored tab via its
2420
- // own watcher on editForm.errors.
2421
- emit('createError', errors);
2422
- }
2423
- });
2424
- } catch (error) {
2425
- emit('createError', error);
2426
- }
2427
- return;
2428
- }
2429
-
2430
- // Edit mode: PUT to editUrl
2431
- if (!selectedItem.value) return;
2432
-
2433
- try {
2434
- // If editUrl provided, handle API call internally
2435
- if (props.editUrl) {
2436
- const itemId = (selectedItem.value as any).id;
2437
- const url = props.editUrl.replace(':id', itemId);
2438
-
2439
- await editForm.value.put(url, {
2440
- transform: stripNonSubmittedFields,
2441
- onSuccess: (data: any) => {
2442
- // Show success toast
2443
- createToast?.({
2444
- title: 'Success',
2445
- body: `${singularItemName.value} updated successfully`,
2446
- variant: 'success',
2447
- modelValue: 3000, // Auto-dismiss after 3 seconds
2448
- });
2449
-
2450
- emit('rowUpdated', selectedItem.value as T, data);
2451
- showEditModal.value = false;
2452
- selectedItem.value = null;
2453
-
2454
- // Refresh table data to show updated values
2455
- refresh();
2456
- },
2457
- onError: (errors: any) => {
2458
- // Extract first error message for toast
2459
- let errorMessage = 'Failed to update. Please check the form for errors.';
2460
- if (errors && typeof errors === 'object') {
2461
- const firstError = Object.values(errors).flat()[0];
2462
- if (typeof firstError === 'string') {
2463
- errorMessage = firstError;
2464
- }
2465
- }
2466
-
2467
- // Show error toast with specific message
2468
- createToast?.({
2469
- title: 'Error',
2470
- body: errorMessage,
2471
- variant: 'danger',
2472
- modelValue: 5000, // Auto-dismiss after 5 seconds
2473
- });
2474
-
2475
- // DXForm switches to the first errored tab via its
2476
- // own watcher on editForm.errors.
2477
- emit('editError', selectedItem.value as T, errors);
2478
- }
2479
- });
2480
- } else {
2481
- // No editUrl - just emit event for custom handling
2482
- emit('rowUpdated', selectedItem.value as T, editForm.value.data);
2483
- showEditModal.value = false;
2484
- selectedItem.value = null;
2485
- }
2486
- } catch (error) {
2487
- emit('editError', selectedItem.value as T, error);
2488
- }
2489
- };
2490
-
2491
- // Handle edit modal close
2492
- const handleEditCancel = () => {
2493
- showEditModal.value = false;
2494
- selectedItem.value = null;
2495
- isCreateMode.value = false;
2496
- activeTabIndex.value = 0; // Reset tab for next time
2497
- // Abandon any in-flight showUrl fetch so it can't seed a closed modal.
2498
- editFetchToken++;
2499
- editLoading.value = false;
2500
- if (editForm.value) {
2501
- editForm.value.clearErrors();
2502
- }
2503
- };
2504
-
2505
- // Handle delete from edit modal
2506
- const handleDelete = async () => {
2507
- if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
2508
- // Don't evaluate the guard until the full record has loaded (showUrl) — it
2509
- // may depend on fields the thin list row doesn't carry.
2510
- if (editLoading.value) return;
2511
-
2512
- // Delete guard: a non-null message means this item can't be deleted — show
2513
- // it immediately and skip the confirm and the request entirely.
2514
- const guardMessage = props.deleteGuard?.(selectedItem.value as T);
2515
- if (guardMessage) {
2516
- createToast?.({
2517
- title: 'Cannot delete',
2518
- body: guardMessage,
2519
- variant: 'danger',
2520
- modelValue: 5000,
2521
- });
2522
- return;
2523
- }
2524
-
2525
- // Confirm deletion
2526
- const itemName = (selectedItem.value as any).name || (selectedItem.value as any).title || singularItemName.value;
2527
- const confirmed = window.confirm(`Are you sure you want to delete "${itemName}"? This action cannot be undone.`);
2528
-
2529
- if (!confirmed) return;
2530
-
2531
- pendingAction.value = 'delete';
2532
- try {
2533
- const itemId = (selectedItem.value as any).id;
2534
- const url = props.deleteUrl.replace(':id', itemId);
2535
-
2536
- await editForm.value.delete(url, {
2537
- onSuccess: (data: any) => {
2538
- // Show success toast
2539
- createToast?.({
2540
- title: 'Success',
2541
- body: `${singularItemName.value} deleted successfully`,
2542
- variant: 'success',
2543
- modelValue: 3000, // Auto-dismiss after 3 seconds
2544
- });
2545
-
2546
- emit('rowDeleted', selectedItem.value as T, data);
2547
- showEditModal.value = false;
2548
- selectedItem.value = null;
2549
-
2550
- // Refresh table data to remove deleted item
2551
- refresh();
2552
- },
2553
- onError: (error: any) => {
2554
- // Extract error message from server response
2555
- const errorData = error?.response?.data ?? error?.data ?? error;
2556
- const errorMessage = errorData?.message ?? 'Failed to delete. Please try again.';
2557
-
2558
- // Show error toast with server message
2559
- createToast?.({
2560
- title: 'Error',
2561
- body: errorMessage,
2562
- variant: 'danger',
2563
- modelValue: 5000, // Auto-dismiss after 5 seconds
2564
- });
2565
-
2566
- emit('deleteError', selectedItem.value as T, error);
2567
- }
2568
- });
2569
- } catch (error) {
2570
- emit('deleteError', selectedItem.value as T, error);
2571
- } finally {
2572
- pendingAction.value = null;
2573
- }
1717
+ editor.openEdit(item);
2574
1718
  };
2575
1719
 
2576
1720
  defineExpose({
@@ -2595,21 +1739,4 @@ defineExpose({
2595
1739
  :deep(tbody tr.dx-row-actionable:hover) {
2596
1740
  background-color: var(--bs-table-hover-bg);
2597
1741
  }
2598
-
2599
- /* Improve pagination button sizing to match form controls */
2600
- :deep(.pagination) {
2601
- margin-bottom: 0;
2602
- }
2603
-
2604
- :deep(.pagination-sm .page-link) {
2605
- min-width: 2.25rem;
2606
- height: auto;
2607
- }
2608
-
2609
- /* Make disabled pagination buttons more subtle */
2610
- :deep(.pagination .page-item.disabled .page-link) {
2611
- background-color: transparent;
2612
- border-color: transparent;
2613
- opacity: 0.3;
2614
- }
2615
1742
  </style>