@omnitend/dashboard-for-laravel 0.22.0 → 0.24.0

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