@omnitend/dashboard-for-laravel 0.22.0 → 0.23.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"
@@ -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 -->
@@ -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 -->
@@ -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
 
@@ -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";
@@ -842,6 +849,45 @@ export interface Props<TItem = any> {
842
849
  /** API endpoint for creating new items (e.g., "/api/products") — enables "New" button */
843
850
  createUrl?: string;
844
851
 
852
+ /**
853
+ * Render the built-in "New {item}" button in the card header (default
854
+ * `true` whenever `createUrl` is set). Set `false` to drive the create
855
+ * modal from your own trigger elsewhere — e.g. a button in the dashboard
856
+ * navbar's actions slot calling the exposed `openCreate()`. With no
857
+ * `title` and no `header` slot, the header is then dropped entirely
858
+ * rather than left empty.
859
+ */
860
+ showCreateButton?: boolean;
861
+
862
+ /**
863
+ * Render a `<tfoot>` mirroring the header row, so `#foot(<key>)` slots can
864
+ * hold per-column totals that line up under their data (#99). Without it
865
+ * there is no footer to put them in. For a fully custom footer, use the
866
+ * `custom-foot` slot instead.
867
+ */
868
+ footClone?: boolean;
869
+
870
+ /**
871
+ * Show a message row when there are no rows (default `true`). An empty
872
+ * table body is indistinguishable from a broken one, so the message is on
873
+ * by default. Override the wording with `emptyText`, or the whole row with
874
+ * the `empty` slot.
875
+ */
876
+ showEmpty?: boolean;
877
+
878
+ /**
879
+ * Message shown when there are no rows. Defaults to "No {items} found", or
880
+ * "No {items} match your filters" when a column filter is active.
881
+ */
882
+ emptyText?: string;
883
+
884
+ /**
885
+ * Rows currently expanded, as a `v-model`. Pair with the `row-expansion`
886
+ * slot to render detail content under a row (#112) instead of forcing every
887
+ * per-row detail into a modal.
888
+ */
889
+ expandedItems?: TItem[];
890
+
845
891
  /** Enable client-side filtering, sorting, and pagination on items array */
846
892
  clientSide?: boolean;
847
893
  }
@@ -872,6 +918,9 @@ const props = withDefaults(defineProps<Props<T>>(), {
872
918
  columnSize: "12",
873
919
  card: true,
874
920
  editModalSize: "lg",
921
+ showCreateButton: true,
922
+ footClone: false,
923
+ showEmpty: true,
875
924
  });
876
925
 
877
926
  const emit = defineEmits<{
@@ -899,6 +948,8 @@ const emit = defineEmits<{
899
948
  deleteError: [item: T, error: any];
900
949
  /** `v-model:sortBy` update, emitted with the normalized single-column sort array when sorting changes. */
901
950
  'update:sortBy': [sortBy: BTableSortBy[]];
951
+ /** `v-model:expandedItems` update, emitted with the rows currently expanded. */
952
+ 'update:expandedItems': [items: T[]];
902
953
  /** `v-model:filters` update, emitted with the new filter map when a column filter changes. */
903
954
  'update:filters': [filters: Record<string, string>];
904
955
  /** `v-model:perPage` update, emitted with the new page size when the per-page selector changes. */
@@ -921,17 +972,51 @@ if (props.clientSide && (props.apiUrl || props.inertiaUrl)) {
921
972
  // Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
922
973
  const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.loading);
923
974
 
924
- // Internal sortBy state for auto modes
925
- const internalSortBy = ref<BTableSortBy[]>([]);
975
+ /*
976
+ * `perPage`, `sortBy` and `filters` are dual-purpose: with a `v-model` they are
977
+ * CONTROLLED state; without one they read as an INITIAL value.
978
+ *
979
+ * DXTable used to treat any passed value as controlled, so `:per-page="50"` —
980
+ * the natural way to say "start with 50 per page" — rendered a working-looking
981
+ * per-page selector that did nothing: the user picked 10, the select showed 10,
982
+ * and the table kept rendering 50 (#110). The control responded, nothing
983
+ * errored, and the table quietly ignored it.
984
+ *
985
+ * A prop is only controlled if the consumer is listening for its update, so
986
+ * that is what we check. It has to come off the raw vnode props: Vue strips
987
+ * DECLARED emit listeners out of `$attrs`, so `useAttrs()` can't see them.
988
+ * Listener presence doesn't meaningfully change over a component's life, so
989
+ * this is resolved once at setup rather than per render.
990
+ */
991
+ const instance = getCurrentInstance();
992
+
993
+ const hasUpdateListener = (name: string): boolean => {
994
+ const vnodeProps = instance?.vnode.props ?? {};
995
+ // `v-model:per-page` compiles to `onUpdate:perPage`, but a hand-written
996
+ // `@update:per-page` stays hyphenated — accept either.
997
+ const hyphenated = name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
998
+ return `onUpdate:${name}` in vnodeProps || `onUpdate:${hyphenated}` in vnodeProps;
999
+ };
1000
+
1001
+ const isControlled = {
1002
+ perPage: hasUpdateListener('perPage'),
1003
+ sortBy: hasUpdateListener('sortBy'),
1004
+ filters: hasUpdateListener('filters'),
1005
+ };
1006
+
1007
+ // Internal sortBy state, seeded from the prop when it's an initial value.
1008
+ const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
926
1009
 
927
- // Computed effective sortBy (use external if provided, otherwise internal)
928
- const effectiveSortBy = computed(() => props.sortBy !== undefined ? props.sortBy : internalSortBy.value);
1010
+ const effectiveSortBy = computed(() =>
1011
+ isControlled.sortBy && props.sortBy !== undefined ? props.sortBy : internalSortBy.value,
1012
+ );
929
1013
 
930
- // Internal filters state for auto modes
931
- const internalFilters = ref<Record<string, string>>({});
1014
+ // Internal filters state, seeded from the prop when it's an initial value.
1015
+ const internalFilters = ref<Record<string, string>>({ ...(props.filters ?? {}) });
932
1016
 
933
- // Computed effective filters (use external if provided, otherwise internal)
934
- const effectiveFilters = computed(() => props.filters !== undefined ? props.filters : internalFilters.value);
1017
+ const effectiveFilters = computed(() =>
1018
+ isControlled.filters && props.filters !== undefined ? props.filters : internalFilters.value,
1019
+ );
935
1020
 
936
1021
  // Computed: check if any field has filtering enabled
937
1022
  const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
@@ -1111,15 +1196,13 @@ watch(() => props.filters, (newFilters, oldFilters) => {
1111
1196
  refresh();
1112
1197
  } else if (hasInertiaUrl.value && isInertiaMode.value && router) {
1113
1198
  // Inertia mode - trigger navigation with new filters
1114
- const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1115
1199
  router.get(
1116
1200
  props.inertiaUrl!,
1117
1201
  {
1118
1202
  page: 1, // Reset to first page when filters change
1119
- sortBy: currentSort.key,
1120
- sortOrder: currentSort.order,
1121
1203
  filters: newFilters,
1122
1204
  perPage: effectivePerPage.value,
1205
+ ...sortParams(effectiveSortBy.value),
1123
1206
  },
1124
1207
  { preserveState: true }
1125
1208
  );
@@ -1152,8 +1235,9 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
1152
1235
 
1153
1236
  // Computed effective perPage (use external if provided, otherwise internal)
1154
1237
  const effectivePerPage = computed(() => {
1155
- // If external perPage prop is provided, use it
1156
- if (props.perPage !== undefined) {
1238
+ // Only when the consumer is actually driving it (v-model). A bare
1239
+ // `:per-page="50"` is an initial value — see `isControlled` (#110).
1240
+ if (isControlled.perPage && props.perPage !== undefined) {
1157
1241
  return props.perPage;
1158
1242
  }
1159
1243
 
@@ -1253,73 +1337,106 @@ const fieldsNeedingFilterValues = computed(() => {
1253
1337
  // Error state for API mode
1254
1338
  const apiError = ref<string | null>(null);
1255
1339
 
1256
- // Internal provider function when apiUrl is provided
1340
+ /**
1341
+ * The active sort, or `null` when there is none.
1342
+ *
1343
+ * The header sort cycles asc → desc → *unsorted*, and in that third state
1344
+ * BTable reports the column with no `order`. There is deliberately no fallback
1345
+ * column here: sorting by a magic `created_at` when the user has asked for no
1346
+ * sort means requesting a column the consumer never declared, which a strict
1347
+ * endpoint (e.g. spatie's QueryBuilder, where an undeclared sort is a 400)
1348
+ * rejects outright. No sort selected means no sort sent — the server applies
1349
+ * its own default ordering. Pass `sortBy` to give the table an initial sort.
1350
+ */
1351
+ const activeSort = (sortBy: readonly BTableSortBy[] | undefined) => {
1352
+ const sort = sortBy?.[0];
1353
+ if (!sort?.key || !sort.order) return null;
1354
+ return { key: sort.key, order: sort.order };
1355
+ };
1356
+
1357
+ // Sort params for a request — an empty object when nothing is sorted.
1358
+ const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
1359
+ const sort = activeSort(sortBy);
1360
+ return sort ? { sortBy: sort.key, sortOrder: sort.order } : {};
1361
+ };
1362
+
1363
+ // Internal provider function when apiUrl is provided. It deliberately does NOT
1364
+ // catch: `effectiveProvider` wraps every provider (including a consumer's own)
1365
+ // in one error handler, so no provider can fail silently.
1257
1366
  const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1258
1367
  if (!props.apiUrl) return [];
1259
1368
 
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
- }
1369
+ const params: any = {
1370
+ page: context.currentPage,
1371
+ perPage: effectivePerPage.value,
1372
+ filters: effectiveFilters.value,
1373
+ ...sortParams(context.sortBy),
1374
+ };
1281
1375
 
1282
- const response = await axios.get(props.apiUrl, { params });
1376
+ // Request filter values on initial load
1377
+ if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
1378
+ params.filterValues = fieldsNeedingFilterValues.value;
1379
+ }
1283
1380
 
1284
- // Extract and store pagination metadata for display
1285
- if (response.data.pagination) {
1286
- apiPaginationMeta.value = response.data.pagination;
1287
- }
1381
+ const response = await axios.get(props.apiUrl, { params });
1288
1382
 
1289
- // Extract and store filter values
1290
- if (response.data.filterValues) {
1291
- apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
1292
- }
1383
+ // Extract and store pagination metadata for display
1384
+ if (response.data.pagination) {
1385
+ apiPaginationMeta.value = response.data.pagination;
1386
+ }
1293
1387
 
1294
- return response.data.data;
1295
- } catch (error: any) {
1296
- console.error('Failed to fetch data from API:', error);
1388
+ // Extract and store filter values
1389
+ if (response.data.filterValues) {
1390
+ apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
1391
+ }
1297
1392
 
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;
1393
+ return response.data.data;
1394
+ };
1303
1395
 
1396
+ /**
1397
+ * Wraps whichever provider is in play — the built-in `apiUrl` one or a
1398
+ * consumer's own `provider` — so a rejected fetch surfaces an error instead of
1399
+ * rendering as zero rows. A failed query and a genuinely empty result look
1400
+ * identical otherwise, which is how a broken table passes review and then shows
1401
+ * a blank page to a user. A consumer's provider used to be the silent case: the
1402
+ * error handling lived inside the internal one, so it never covered them.
1403
+ */
1404
+ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1405
+ const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
1406
+ if (!provider) return [];
1407
+
1408
+ apiError.value = null;
1409
+ try {
1410
+ return await provider(context);
1411
+ } catch (error: any) {
1412
+ // eslint-disable-next-line no-console
1413
+ console.error('DXTable: the data provider failed', error);
1414
+ apiError.value =
1415
+ error?.response?.data?.message ||
1416
+ error?.message ||
1417
+ 'Failed to load data. Please try again.';
1304
1418
  return [];
1305
1419
  }
1306
1420
  };
1307
1421
 
1308
- // Computed effective provider (use external if provided, otherwise internal)
1309
- const effectiveProvider = computed(() => props.provider || (props.apiUrl ? internalProvider : undefined));
1422
+ // `wrappedProvider` is resolved fresh on each call, so this computed's IDENTITY
1423
+ // must not change when `apiUrl` does — BTable refetches when the provider
1424
+ // function changes, which would double every request that the explicit apiUrl
1425
+ // watcher already refetches (#82).
1426
+ const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
1427
+ props.provider || props.apiUrl ? wrappedProvider : undefined,
1428
+ );
1310
1429
 
1311
1430
  const handlePageChange = (page: number) => {
1312
1431
  // If inertiaUrl provided, handle navigation automatically
1313
1432
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1314
- const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1315
1433
  router.get(
1316
1434
  props.inertiaUrl!,
1317
1435
  {
1318
1436
  page,
1319
- sortBy: currentSort.key,
1320
- sortOrder: currentSort.order,
1321
1437
  filters: effectiveFilters.value,
1322
1438
  perPage: effectivePerPage.value,
1439
+ ...sortParams(effectiveSortBy.value),
1323
1440
  },
1324
1441
  { preserveState: true }
1325
1442
  );
@@ -1353,8 +1470,8 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
1353
1470
  }
1354
1471
  }
1355
1472
 
1356
- // Update internal state if not using external sortBy
1357
- if (props.sortBy === undefined) {
1473
+ // Write internal state unless the consumer controls it with v-model.
1474
+ if (!isControlled.sortBy) {
1358
1475
  internalSortBy.value = normalizedSortBy;
1359
1476
  }
1360
1477
 
@@ -1363,28 +1480,26 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
1363
1480
 
1364
1481
  // Handle Inertia navigation automatically if URL provided
1365
1482
  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 });
1483
+ // The cleared state has a key but no order — `sortParams` sends nothing
1484
+ // for it, rather than inventing an ascending sort the user didn't ask for.
1485
+ router.get(
1486
+ props.inertiaUrl!,
1487
+ {
1488
+ page: props.pagination?.current_page || 1,
1489
+ filters: effectiveFilters.value,
1490
+ perPage: effectivePerPage.value,
1491
+ ...sortParams(normalizedSortBy),
1492
+ },
1493
+ { preserveState: true },
1494
+ );
1380
1495
  }
1381
1496
 
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
- });
1497
+ // Emit simplified sortChange event for backward compatibility. Its payload
1498
+ // can only express an ACTIVE sort, so the cleared state emits nothing here —
1499
+ // listen to `update:sortBy` (which carries the empty array) to observe it.
1500
+ const sort = activeSort(normalizedSortBy);
1501
+ if (isInertiaMode.value && sort) {
1502
+ emit('sortChange', sort);
1388
1503
  }
1389
1504
  };
1390
1505
 
@@ -1404,8 +1519,8 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1404
1519
  }
1405
1520
  const perPageNum = typeof rawPerPage === 'string' ? parseInt(rawPerPage, 10) : rawPerPage;
1406
1521
 
1407
- // Update internal state if not using external perPage
1408
- if (props.perPage === undefined) {
1522
+ // Write internal state unless the consumer controls it with v-model.
1523
+ if (!isControlled.perPage) {
1409
1524
  internalPerPage.value = perPageNum;
1410
1525
  }
1411
1526
 
@@ -1423,15 +1538,13 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1423
1538
 
1424
1539
  // Handle navigation automatically
1425
1540
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1426
- const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1427
1541
  router.get(
1428
1542
  props.inertiaUrl!,
1429
1543
  {
1430
1544
  page: 1, // Reset to first page when changing perPage
1431
- sortBy: currentSort.key,
1432
- sortOrder: currentSort.order,
1433
1545
  filters: effectiveFilters.value,
1434
1546
  perPage: perPageNum,
1547
+ ...sortParams(effectiveSortBy.value),
1435
1548
  },
1436
1549
  { preserveState: true }
1437
1550
  );
@@ -1455,8 +1568,8 @@ const handleFilterChange = (fieldKey: string, value: string) => {
1455
1568
  delete newFilters[fieldKey];
1456
1569
  }
1457
1570
 
1458
- // Update internal state if not using external filters
1459
- if (props.filters === undefined) {
1571
+ // Write internal state unless the consumer controls it with v-model.
1572
+ if (!isControlled.filters) {
1460
1573
  internalFilters.value = newFilters;
1461
1574
  }
1462
1575
 
@@ -1478,14 +1591,12 @@ const handleFilterChange = (fieldKey: string, value: string) => {
1478
1591
  filterDebounceTimer = setTimeout(() => {
1479
1592
  // Handle Inertia navigation automatically if URL provided
1480
1593
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1481
- const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1482
1594
  router.get(
1483
1595
  props.inertiaUrl!,
1484
1596
  {
1485
1597
  page: 1, // Reset to first page when filtering
1486
- sortBy: currentSort.key,
1487
- sortOrder: currentSort.order,
1488
1598
  filters: newFilters,
1599
+ ...sortParams(effectiveSortBy.value),
1489
1600
  },
1490
1601
  { preserveState: true }
1491
1602
  );
@@ -1519,6 +1630,12 @@ const refresh = () => {
1519
1630
 
1520
1631
  // Edit Modal State
1521
1632
  const showEditModal = ref(false);
1633
+
1634
+ // Bumped on every modal open (edit or create) to key the DXForm subtree, so
1635
+ // each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
1636
+ // which reseeds the SAME record — remounting there would discard edits the
1637
+ // user has already made while the fetch was in flight.
1638
+ const editFormInstanceKey = ref(0);
1522
1639
  const selectedItem = ref<T | null>(null);
1523
1640
  const editForm = ref<any>(null);
1524
1641
  const activeTabIndex = ref(0);
@@ -1554,6 +1671,53 @@ try {
1554
1671
  // DXForm doesn't mistake an always-present (but empty) wrapper for
1555
1672
  // a real custom-value override.
1556
1673
  const tableSlots = useSlots();
1674
+
1675
+ /*
1676
+ * Slot forwarding to the inner table.
1677
+ *
1678
+ * DXTable used to forward only slots whose name started with `cell`, which is
1679
+ * why it had no footer/totals row (#99), no empty state (#111) and no
1680
+ * expandable rows (#112): BTable supports all three, but the slots never
1681
+ * reached it. Anything BTable understands is now forwarded, with two
1682
+ * deliberate exceptions that DXTable renders itself:
1683
+ *
1684
+ * - `head(<key>)` — DXTable draws its own column headers (sort indicators,
1685
+ * field hints). Forwarding a consumer's would silently drop those.
1686
+ * - `thead-top` — DXTable renders the inline filter row there.
1687
+ */
1688
+ const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
1689
+ const TABLE_SLOT_NAMES = new Set([
1690
+ 'custom-foot',
1691
+ 'empty',
1692
+ 'empty-filtered',
1693
+ 'row-expansion',
1694
+ 'table-busy',
1695
+ 'table-caption',
1696
+ 'table-colgroup',
1697
+ 'thead-sub',
1698
+ 'top-row',
1699
+ 'bottom-row',
1700
+ ]);
1701
+
1702
+ const isTableSlot = (name: string) =>
1703
+ TABLE_SLOT_NAMES.has(name) || TABLE_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix));
1704
+
1705
+ const forwardedTableSlotNames = computed(() =>
1706
+ Object.keys(tableSlots).filter((name) => isTableSlot(name)),
1707
+ );
1708
+
1709
+ // Table-level features of the inner BTable, exposed verbatim and bound to all
1710
+ // three data modes from one place.
1711
+ const tablePassthroughProps = computed(() => ({
1712
+ footClone: props.footClone,
1713
+ showEmpty: props.showEmpty,
1714
+ emptyText:
1715
+ props.emptyText ??
1716
+ (hasActiveFilters.value
1717
+ ? `No ${pluralItemName.value} match your filters`
1718
+ : `No ${pluralItemName.value} found`),
1719
+ expandedItems: props.expandedItems,
1720
+ }));
1557
1721
  const editFieldKeys = computed<string[]>(() =>
1558
1722
  (props.editFields ?? []).map((field: any) => field.key),
1559
1723
  );
@@ -1580,6 +1744,11 @@ const tabAfterSlotKeys = computed(() =>
1580
1744
  const singularItemName = computed(() => props.itemName);
1581
1745
  const pluralItemName = computed(() => pluralize(props.itemName));
1582
1746
 
1747
+ // The built-in "New {item}" button needs both a create endpoint and consent.
1748
+ // Gating the header's own v-if on this (not on `createUrl`) means a table that
1749
+ // only had a header to host the button loses the empty header too.
1750
+ const showsCreateButton = computed(() => Boolean(props.createUrl) && props.showCreateButton);
1751
+
1583
1752
  // Computed: Modal title (supports function)
1584
1753
  const computedModalTitle = computed(() => {
1585
1754
  if (isCreateMode.value) {
@@ -1633,6 +1802,7 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1633
1802
  }
1634
1803
 
1635
1804
  // Open modal
1805
+ editFormInstanceKey.value++;
1636
1806
  showEditModal.value = true;
1637
1807
 
1638
1808
  // Optionally replace the row-seeded data with the full record.
@@ -1707,6 +1877,7 @@ const handleCreateNew = () => {
1707
1877
  });
1708
1878
  editForm.value.clearErrors();
1709
1879
  }
1880
+ editFormInstanceKey.value++;
1710
1881
  showEditModal.value = true;
1711
1882
  };
1712
1883