@omnitend/dashboard-for-laravel 0.33.1 → 0.35.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.
Files changed (57) hide show
  1. package/dist/charts.js +82 -90
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.umd.cjs +1 -1
  4. package/dist/charts.umd.cjs.map +1 -1
  5. package/dist/components/base/DButton.vue.d.ts +1 -1
  6. package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
  7. package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
  8. package/dist/components/charts/useThemedChart.d.ts +35 -0
  9. package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
  10. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
  11. package/dist/components/extended/DXField.vue.d.ts +10 -113
  12. package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
  13. package/dist/components/extended/DXForm.vue.d.ts +28 -6
  14. package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
  15. package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
  16. package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
  17. package/dist/components/extended/DXTable.vue.d.ts +107 -2
  18. package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
  19. package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
  20. package/dist/composables/useAsyncOptions.d.ts +20 -0
  21. package/dist/composables/useContainerWidth.d.ts +96 -0
  22. package/dist/composables/useResourceEditor.d.ts +2 -1
  23. package/dist/dashboard-for-laravel.js +10513 -10443
  24. package/dist/dashboard-for-laravel.js.map +1 -1
  25. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  26. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  27. package/dist/index.d.ts +5 -2
  28. package/dist/style.css +1 -1
  29. package/dist/types/index.d.ts +146 -0
  30. package/dist/utils/formSchema.d.ts +59 -0
  31. package/docs/public/api-reference.json +494 -33
  32. package/docs/public/docs-map.md +1 -1
  33. package/docs/public/llms.txt +5 -2
  34. package/package.json +1 -1
  35. package/resources/css/theme.scss +80 -2
  36. package/resources/js/components/charts/DXBarChart.vue +4 -43
  37. package/resources/js/components/charts/DXLineChart.vue +4 -43
  38. package/resources/js/components/charts/useThemedChart.ts +69 -0
  39. package/resources/js/components/extended/DXChoiceField.vue +114 -0
  40. package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
  41. package/resources/js/components/extended/DXField.vue +328 -615
  42. package/resources/js/components/extended/DXFieldShell.vue +124 -0
  43. package/resources/js/components/extended/DXForm.vue +92 -14
  44. package/resources/js/components/extended/DXNumericField.vue +144 -0
  45. package/resources/js/components/extended/DXRepeater.vue +53 -175
  46. package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
  47. package/resources/js/components/extended/DXTable.vue +418 -43
  48. package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
  49. package/resources/js/components/extended/DXTablePagination.vue +19 -3
  50. package/resources/js/components/extended/DXTableShell.vue +70 -1
  51. package/resources/js/composables/defineForm.ts +8 -44
  52. package/resources/js/composables/useAsyncOptions.ts +61 -0
  53. package/resources/js/composables/useContainerWidth.ts +303 -0
  54. package/resources/js/composables/useResourceEditor.ts +24 -38
  55. package/resources/js/index.ts +40 -0
  56. package/resources/js/types/index.ts +231 -0
  57. package/resources/js/utils/formSchema.ts +126 -0
@@ -31,7 +31,7 @@
31
31
  request is usually caused by the sort or a filter, and
32
32
  replacing the table would take away the very controls
33
33
  needed to undo it, leaving a reload as the only way out. -->
34
- <div v-if="error || apiError" class="alert alert-danger">
34
+ <div v-if="error || (apiError && isProviderMode)" class="alert alert-danger">
35
35
  {{ error || apiError }}
36
36
  </div>
37
37
 
@@ -49,7 +49,7 @@
49
49
  <DTable
50
50
  v-else-if="isProviderMode || isClientSideMode || isInertiaMode"
51
51
  ref="tableRef"
52
- :key="tableSlotSignature($slots)"
52
+ :key="activeMode + ':' + tableSlotSignature($slots)"
53
53
  :fields="fields"
54
54
  :sort-by="effectiveSortBy"
55
55
  :multisort="false"
@@ -163,6 +163,35 @@
163
163
  <div class="fw-semibold">{{ headerLabel(field, label) }}</div>
164
164
  <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
165
165
  </div>
166
+
167
+ <!-- ADDITIVE per-column header content (#99), rendered
168
+ INSIDE DXTable's own header cell rather than as a
169
+ `head(<key>)` override — an override would silently
170
+ drop the sort indicator and the field hint, which is
171
+ exactly why `head(...)` is not forwarded (see
172
+ `isTableSlot`). Placed after the label block and
173
+ BEFORE the sort indicator so a period total sits at
174
+ the top-right of the text area without displacing the
175
+ sort arrows, and lands in the same place whether or
176
+ not the column happens to be sortable. The wrapper is
177
+ only emitted when the slot exists, so a table without
178
+ one renders exactly as before. -->
179
+ <div
180
+ v-if="$slots[`head-end(${field.key})`]"
181
+ class="dx-head-end flex-shrink-0 text-end"
182
+ >
183
+ <!--
184
+ @slot head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.
185
+ @binding {object} field The column's field definition.
186
+ @binding {string} label The column's resolved header label.
187
+ -->
188
+ <slot
189
+ :name="`head-end(${field.key})`"
190
+ :field="field"
191
+ :label="headerLabel(field, label)"
192
+ />
193
+ </div>
194
+
166
195
  <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;">
167
196
  <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
168
197
  <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
@@ -239,6 +268,13 @@
239
268
  :is-create-mode="isCreateMode"
240
269
  :pending-action="pendingAction"
241
270
  :delete-url="deleteUrl"
271
+ :layout="editLayout"
272
+ :label-cols="editLabelCols"
273
+ :card="editCard"
274
+ :item-name="itemName"
275
+ :save-text="saveText"
276
+ :create-text="createText"
277
+ :delete-text="deleteText"
242
278
  @save="handleEditSave"
243
279
  @cancel="handleEditCancel"
244
280
  @delete="handleDelete"
@@ -266,6 +302,7 @@ import { api } from "../../utils/api";
266
302
  import pluralize from "pluralize";
267
303
  import { useResourceEditor } from "../../composables/useResourceEditor";
268
304
  import { getByPath } from "../../utils/objectPath";
305
+ import type { FieldDefinition, LabelCols } from "../../types";
269
306
  import DContainer from "../base/DContainer.vue";
270
307
  import DRow from "../base/DRow.vue";
271
308
  import DCol from "../base/DCol.vue";
@@ -351,6 +388,22 @@ export interface TableField {
351
388
  filterOptions?: FilterOption[];
352
389
  filterPlaceholder?: string;
353
390
 
391
+ /**
392
+ * For a `select` filter on a **client-side** table: derive the dropdown's
393
+ * options from the distinct values present in the loaded rows. Defaults to
394
+ * `true` — with no `filterOptions` and no server `filterValues` the
395
+ * alternative is an empty dropdown, which can only ever be a bug.
396
+ *
397
+ * Set `false` to opt out (e.g. a column whose raw values are opaque ids you
398
+ * would rather label yourself via `filterOptions`).
399
+ *
400
+ * Explicit `filterOptions` and server-supplied `filterValues` still WIN, in
401
+ * that order. Provider/API and Inertia modes ignore this flag: they only
402
+ * ever hold one page of rows, so there is no complete value set to derive
403
+ * from (see `getFieldFilterOptions`).
404
+ */
405
+ deriveFilterOptions?: boolean;
406
+
354
407
  /**
355
408
  * Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
356
409
  * is treated as pixels (`200` → `200px`); a string is used verbatim
@@ -415,6 +468,27 @@ export interface EditTab {
415
468
  lazy?: boolean;
416
469
  }
417
470
 
471
+ /**
472
+ * Discriminated data-source descriptor (#130). The additive, type-safe
473
+ * replacement for the legacy `items` / `provider` / `apiUrl` / `inertiaUrl` /
474
+ * `clientSide` prop matrix, in which every prop was independently optional and
475
+ * the mode was inferred by precedence — so invalid combinations compiled and
476
+ * some were silently ignored.
477
+ *
478
+ * Pass ONE `source`: the mode is explicit and its required companion prop is
479
+ * enforced by the compiler (`{ mode: 'provider' }` without a `provider` won't
480
+ * type-check). The legacy props still work and take over when `source` is
481
+ * omitted; a future major removes them with a codemod (#130 Phase 3).
482
+ *
483
+ * `apiAdapter` (transport translation) and `pagination` (server-supplied page
484
+ * metadata) stay sibling props — they're orthogonal to which mode is active.
485
+ */
486
+ export type DXTableSource<TItem = any> =
487
+ | { mode: 'client'; items: TItem[] }
488
+ | { mode: 'provider'; provider: BTableProvider<TItem> }
489
+ | { mode: 'api'; url: string }
490
+ | { mode: 'inertia'; items: TItem[]; url?: string };
491
+
418
492
  export interface Props<TItem = any> {
419
493
  /** Table title */
420
494
  title?: string;
@@ -435,6 +509,14 @@ export interface Props<TItem = any> {
435
509
  * backend convention that differs from dfl's (see DXTableApiAdapter). */
436
510
  apiAdapter?: DXTableApiAdapter;
437
511
 
512
+ /**
513
+ * Discriminated data-source (#130) — the type-safe alternative to the
514
+ * legacy `items` / `provider` / `apiUrl` / `inertiaUrl` / `clientSide`
515
+ * props. When set it WINS over those props; when omitted, the legacy props
516
+ * apply unchanged. See `DXTableSource`.
517
+ */
518
+ source?: DXTableSource<TItem>;
519
+
438
520
  /** Table field definitions */
439
521
  fields: TableField[];
440
522
 
@@ -522,12 +604,29 @@ export interface Props<TItem = any> {
522
604
  * borderless variant — the same header, filter row, table and pagination
523
605
  * rendered directly on the page background (no card border/shadow), for
524
606
  * data-heavy admin index pages that read as busy inside a card.
607
+ *
608
+ * The table renders FLUSH to the card border (rows and stripes reach the
609
+ * edge) while the header and pagination stay padded, which requires the card
610
+ * to clip to its radius (`overflow: hidden`).
611
+ *
612
+ * **Consequence for slot content:** anything you render into a slot that
613
+ * positions itself OUTSIDE the card and does not teleport is clipped at the
614
+ * card edge. Library components are unaffected — `DDropdown` teleports to
615
+ * `<body>` by default, and the column-filter menus position themselves
616
+ * `fixed` — so this only bites a hand-rolled `position: absolute` overlay
617
+ * (a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or
618
+ * use `:card="false"`. Narrowing the clip to the table region is tracked
619
+ * as #166.
525
620
  */
526
621
  card?: boolean;
527
622
 
528
623
  // Edit Modal Props
529
- /** Form field definitions for edit modal (if provided, enables edit on row click) */
530
- editFields?: any[]; // FieldDefinition[] - using any to avoid circular import
624
+ /**
625
+ * Form field definitions for edit modal (if provided, enables edit on row
626
+ * click). `import type` is erased at compile time, so this creates no
627
+ * runtime import cycle (#131).
628
+ */
629
+ editFields?: FieldDefinition[];
531
630
 
532
631
  /** Tab definitions for organizing edit modal content */
533
632
  editTabs?: EditTab[];
@@ -538,6 +637,43 @@ export interface Props<TItem = any> {
538
637
  /** Edit modal size */
539
638
  editModalSize?: 'sm' | 'md' | 'lg' | 'xl';
540
639
 
640
+ /**
641
+ * Field layout for the edit/create modal's form, forwarded to its `DXForm`
642
+ * ("vertical" = label above input, "horizontal" = label left). Defaults to
643
+ * **"horizontal"** to match the Omni Tend form convention (a raw modal form
644
+ * previously fell back to DXForm's vertical default, which looked out of
645
+ * place next to horizontal page forms). Pass `"vertical"` to opt out.
646
+ *
647
+ * `"auto"` hands the decision to `DXForm`'s container-width observer, which
648
+ * measures the FORM rather than the window — so a modal narrowed by its own
649
+ * breakpoint stacks to vertical even on a wide viewport, where a media query
650
+ * would still report "plenty of room". See `useContainerWidth`.
651
+ */
652
+ editLayout?: 'vertical' | 'horizontal' | 'auto';
653
+
654
+ /**
655
+ * Label column width for the edit modal's horizontal layout, forwarded to
656
+ * `DXForm`'s `labelCols` (a single width or a per-breakpoint object). Only
657
+ * meaningful when `editLayout` is "horizontal".
658
+ */
659
+ editLabelCols?: LabelCols;
660
+
661
+ /**
662
+ * Wrap the edit/create modal's form in a bordered card. Off by default (the
663
+ * modal body already provides a boundary); opt in for a more contained,
664
+ * panelled look around a tabbed edit form.
665
+ */
666
+ editCard?: boolean;
667
+
668
+ /** Override the modal's Save button label (default: "Save {item}"). */
669
+ saveText?: string;
670
+
671
+ /** Override the modal's Create button label (default: "Create {item}"). */
672
+ createText?: string;
673
+
674
+ /** Override the modal's Delete button label (default: "Delete {item}"). */
675
+ deleteText?: string;
676
+
541
677
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
542
678
  editUrl?: string;
543
679
 
@@ -663,6 +799,8 @@ const props = withDefaults(defineProps<Props<T>>(), {
663
799
  columnSize: "12",
664
800
  card: true,
665
801
  editModalSize: "lg",
802
+ editLayout: "horizontal",
803
+ editCard: false,
666
804
  showCreateButton: true,
667
805
  footClone: false,
668
806
  showEmpty: true,
@@ -704,15 +842,74 @@ const emit = defineEmits<{
704
842
  'update:busy': [busy: boolean];
705
843
  }>();
706
844
 
845
+ // Effective data-source values (#130). Prefer an explicit `source` prop, else
846
+ // fall back to the legacy `items`/`provider`/`apiUrl`/`inertiaUrl`/`clientSide`
847
+ // props. Every read below (mode detection, provider, client-side pipeline,
848
+ // inertia navigation) goes through these, so both APIs drive identical logic.
849
+ const resolvedClientSide = computed(() =>
850
+ props.source ? props.source.mode === 'client' : props.clientSide === true,
851
+ );
852
+ const resolvedItems = computed<T[] | undefined>(() => {
853
+ if (props.source) {
854
+ return props.source.mode === 'client' || props.source.mode === 'inertia'
855
+ ? props.source.items
856
+ : undefined;
857
+ }
858
+ return props.items;
859
+ });
860
+ const resolvedProvider = computed<BTableProvider<T> | undefined>(() =>
861
+ props.source
862
+ ? props.source.mode === 'provider'
863
+ ? props.source.provider
864
+ : undefined
865
+ : props.provider,
866
+ );
867
+ const resolvedApiUrl = computed<string | undefined>(() =>
868
+ props.source
869
+ ? props.source.mode === 'api'
870
+ ? props.source.url
871
+ : undefined
872
+ : props.apiUrl,
873
+ );
874
+ const resolvedInertiaUrl = computed<string | undefined>(() =>
875
+ props.source
876
+ ? props.source.mode === 'inertia'
877
+ ? props.source.url
878
+ : undefined
879
+ : props.inertiaUrl,
880
+ );
881
+
707
882
  // Mode detection
708
- const isProviderMode = computed(() => !props.clientSide && (!!props.provider || !!props.apiUrl));
709
- const isInertiaMode = computed(() => !props.clientSide && !props.provider && !props.apiUrl && !!props.items);
710
- const isClientSideMode = computed(() => props.clientSide === true && !!props.items);
711
- const hasInertiaUrl = computed(() => !!props.inertiaUrl);
712
-
713
- // Warn about invalid prop combinations in client-side mode
714
- if (props.clientSide && (props.apiUrl || props.inertiaUrl)) {
715
- console.warn('[DXTable] clientSide mode ignores apiUrl and inertiaUrl props. Data is processed locally from items.');
883
+ const isProviderMode = computed(() => !resolvedClientSide.value && (!!resolvedProvider.value || !!resolvedApiUrl.value));
884
+ const isInertiaMode = computed(() => !resolvedClientSide.value && !resolvedProvider.value && !resolvedApiUrl.value && !!resolvedItems.value);
885
+ const isClientSideMode = computed(() => resolvedClientSide.value && !!resolvedItems.value);
886
+ const hasInertiaUrl = computed(() => !!resolvedInertiaUrl.value);
887
+
888
+ // The active data-source mode, folded into the table's `:key` so a runtime
889
+ // mode switch (e.g. `source` provider → client) REMOUNTS the DTable. bvn's
890
+ // BTable caches its provider-fetched items internally and doesn't cleanly adopt
891
+ // a fresh `:items` binding when the provider is removed — without a remount the
892
+ // table shows "No items found" after switching to client/inertia mode. Stable
893
+ // for a table that never changes mode, so it adds no spurious remounts.
894
+ const activeMode = computed(() =>
895
+ isClientSideMode.value
896
+ ? 'client'
897
+ : isInertiaMode.value
898
+ ? 'inertia'
899
+ : isProviderMode.value
900
+ ? 'provider'
901
+ : 'empty',
902
+ );
903
+
904
+ // Warn about invalid LEGACY prop combinations (a `source` prop makes these
905
+ // impossible at the type level, so it's exempt). #130: `clientSide + provider`
906
+ // was previously uncovered by this guard.
907
+ if (
908
+ !props.source &&
909
+ props.clientSide &&
910
+ (props.provider || props.apiUrl || props.inertiaUrl)
911
+ ) {
912
+ console.warn('[DXTable] clientSide mode ignores provider, apiUrl and inertiaUrl props. Data is processed locally from items.');
716
913
  }
717
914
 
718
915
  // Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
@@ -835,7 +1032,7 @@ const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
835
1032
 
836
1033
  const providerPagination = computed<PaginationData | null>(() => {
837
1034
  if (apiPaginationMeta.value) return apiPaginationMeta.value;
838
- if (props.provider && paginationWasProvided && props.pagination) {
1035
+ if (resolvedProvider.value && paginationWasProvided && props.pagination) {
839
1036
  return props.pagination;
840
1037
  }
841
1038
  return null;
@@ -850,7 +1047,7 @@ const providerPagination = computed<PaginationData | null>(() => {
850
1047
  const providerHasRun = ref(false);
851
1048
  let warnedAboutProviderPagination = false;
852
1049
  watch(
853
- () => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
1050
+ () => [resolvedProvider.value, props.showPagination, providerPagination.value, providerHasRun.value] as const,
854
1051
  ([provider, showPagination, pagination, hasRun]) => {
855
1052
  if (warnedAboutProviderPagination || !hasRun) return;
856
1053
  if (provider && showPagination && !pagination) {
@@ -873,29 +1070,20 @@ const clientSideCurrentPage = ref(1);
873
1070
 
874
1071
  // Client-side filtered items
875
1072
  const clientSideFilteredItems = computed<T[]>(() => {
876
- if (!isClientSideMode.value || !props.items) return [];
1073
+ if (!isClientSideMode.value || !resolvedItems.value) return [];
877
1074
 
878
1075
  const filters = effectiveFilters.value;
879
1076
  const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
880
1077
 
881
1078
  if (filterKeys.length === 0) {
882
- return props.items;
1079
+ return resolvedItems.value;
883
1080
  }
884
1081
 
885
- return props.items.filter(item => {
1082
+ return resolvedItems.value.filter(item => {
886
1083
  return filterKeys.every(key => {
887
1084
  // Resolve by the FILTER key, which may differ from the column's key.
888
1085
  const field = props.fields.find(f => filterKeyFor(f) === key);
889
- // `filterKey` names the param the SERVER filters on; a local row may
890
- // not carry it at all (a `customer_name` row filtered on
891
- // `customer_id`). Match on the filter key when the row has it,
892
- // otherwise fall back to the column's own value — which is also the
893
- // one the user can actually see and type against.
894
- // Both lookups are dot-path aware, so a nested payload filters (#121).
895
- const itemValue =
896
- field && hasFieldValue(item, key)
897
- ? fieldValueOf(item, key)
898
- : fieldValueOf(item, field?.key ?? key);
1086
+ const itemValue = clientSideFilterValueOf(item, field, key);
899
1087
 
900
1088
  const matchesCandidate = (candidate: string): boolean => {
901
1089
  // The "no value" option (e.g. Unassigned) is the one filter that
@@ -1017,6 +1205,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
1017
1205
  return getByPath(item as Record<string, any>, key) !== undefined;
1018
1206
  };
1019
1207
 
1208
+ /*
1209
+ * The value a client-side filter compares against, for one row and one column.
1210
+ *
1211
+ * `filterKey` names the param the SERVER filters on; a local row may not carry
1212
+ * it at all (a `customer_name` row filtered on `customer_id`). Read the filter
1213
+ * key when the row has it, otherwise fall back to the column's own value —
1214
+ * which is also the one the user can actually see and type against. Both
1215
+ * lookups are dot-path aware, so a nested payload filters (#121).
1216
+ *
1217
+ * Extracted so the client-side row filter and the DERIVED select options (see
1218
+ * `derivedFilterOptions`) resolve values through exactly the same rule — a
1219
+ * derived option must never be a value the filter then fails to match.
1220
+ */
1221
+ const clientSideFilterValueOf = (
1222
+ item: unknown,
1223
+ field: TableField | undefined,
1224
+ filterKey: string,
1225
+ ): any =>
1226
+ field && hasFieldValue(item, filterKey)
1227
+ ? fieldValueOf(item, filterKey)
1228
+ : fieldValueOf(item, field?.key ?? filterKey);
1229
+
1020
1230
  /*
1021
1231
  * Dotted-key columns DXTable must render itself, because bvn renders them empty
1022
1232
  * (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
@@ -1101,6 +1311,115 @@ const filterAllLabelFor = (field: TableField): string =>
1101
1311
  const headerLabel = (field: TableField, slotLabel?: string): string =>
1102
1312
  field.label !== undefined ? field.label : (slotLabel || field.key);
1103
1313
 
1314
+ /**
1315
+ * A select filter option's LABEL, honouring the column's own value→label
1316
+ * formatting so the dropdown reads the way the column does (a `formatter`
1317
+ * turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
1318
+ * the client-side filter compares against (`String(itemValue) === candidate`),
1319
+ * so formatting must never leak into it.
1320
+ *
1321
+ * `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
1322
+ * its first argument is by contract the value of `field.key` — the DISPLAYED
1323
+ * column value. That matters when `filterKey` names a different property: the
1324
+ * option's value must stay the FILTER value (that is what the row filter
1325
+ * compares), but the formatter must still be handed the display value it was
1326
+ * written against. Passing the filter value here fed a `customer` column's
1327
+ * `(customer) => customer.name` formatter a numeric `customer_id`, which
1328
+ * mislabels at best and throws at worst.
1329
+ */
1330
+ const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
1331
+ if (typeof field.formatter !== 'function') return null;
1332
+ const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
1333
+ if (formatted === null || formatted === undefined) return null;
1334
+ return String(formatted);
1335
+ };
1336
+
1337
+ /**
1338
+ * Client-side auto-derived `filter: "select"` options, keyed by field key.
1339
+ *
1340
+ * Without this a client-side select column with no `filterOptions` and no
1341
+ * server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
1342
+ * bug — so this is on by default (opt out per column with
1343
+ * `deriveFilterOptions: false`).
1344
+ *
1345
+ * It derives from `resolvedItems` (the FULL loaded row set) and deliberately
1346
+ * NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
1347
+ * - filtered set → picking a value would collapse the dropdown to that one
1348
+ * value, and the user could never get back to any other one;
1349
+ * - current page → the offered values would silently change as you page.
1350
+ * Being a computed over `props.fields` + `resolvedItems` only, it is also
1351
+ * structurally incapable of depending on the active filters — the cache is the
1352
+ * guard, not just the comment.
1353
+ */
1354
+ const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
1355
+ // Only client-side mode has the complete row set. Provider/API and Inertia
1356
+ // hold one page, so they take their options from the server instead.
1357
+ if (!isClientSideMode.value) return {};
1358
+
1359
+ const rows = resolvedItems.value;
1360
+ if (!rows || rows.length === 0) return {};
1361
+
1362
+ const optionsByFieldKey: Record<string, FilterOption[]> = {};
1363
+
1364
+ for (const field of props.fields) {
1365
+ if (field.filter !== 'select') continue;
1366
+ if (field.deriveFilterOptions === false) continue;
1367
+ // Explicit options win outright (see `getFieldFilterOptions`), so don't
1368
+ // spend a pass over every row producing a list nothing will read.
1369
+ if (field.filterOptions && field.filterOptions.length > 0) continue;
1370
+
1371
+ const filterKey = filterKeyFor(field);
1372
+
1373
+ // Distinct values keyed by their STRING form, because that is exactly
1374
+ // what the client-side select filter compares — so `1` and `"1"` are one
1375
+ // option, and every option offered is one the filter can match. The
1376
+ // first row carrying a value is kept alongside it, as the `item`
1377
+ // argument the column's formatter expects.
1378
+ const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
1379
+
1380
+ // Candidates the filter reads as CONTROL values rather than data, so a
1381
+ // row whose value collides with one can never be matched by selecting it
1382
+ // (`handleSelectFilterChange` turns the "all" sentinel back into "no
1383
+ // filter", and the null sentinel is intercepted upstream of the select's
1384
+ // exact-match arm, where it means "has no value"). Offering such an
1385
+ // option would be a dropdown entry that returns zero rows, and the null
1386
+ // one would also duplicate the `filterNullText` entry's value.
1387
+ const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
1388
+ if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
1389
+
1390
+ for (const item of rows) {
1391
+ const value = clientSideFilterValueOf(item, field, filterKey);
1392
+ // null / undefined / '' are skipped deliberately: "has no value" is
1393
+ // already a first-class, LABELLED option via `filterNullText`, and
1394
+ // an unlabelled blank row in the list would be unusable.
1395
+ if (value === null || value === undefined || value === '') continue;
1396
+ const stringValue = String(value);
1397
+ if (reservedCandidates.has(stringValue)) continue;
1398
+ if (!firstRowByValue.has(stringValue)) {
1399
+ firstRowByValue.set(stringValue, { rawValue: value, item });
1400
+ }
1401
+ }
1402
+
1403
+ const entries = [...firstRowByValue.entries()];
1404
+
1405
+ // Stable, human ordering: numeric when both values are numbers,
1406
+ // otherwise a locale compare — the same rule the client-side sort uses,
1407
+ // with `numeric: true` so "Item 2" precedes "Item 10".
1408
+ entries.sort(([leftText, left], [rightText, right]) =>
1409
+ typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
1410
+ ? left.rawValue - right.rawValue
1411
+ : leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
1412
+ );
1413
+
1414
+ optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
1415
+ value: stringValue,
1416
+ text: formattedFilterLabel(field, item) ?? stringValue,
1417
+ }));
1418
+ }
1419
+
1420
+ return optionsByFieldKey;
1421
+ });
1422
+
1104
1423
  const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1105
1424
  const options: FilterOption[] = [];
1106
1425
 
@@ -1140,6 +1459,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1140
1459
  return [...options, ...serverValues.map(value => ({ value, text: value }))];
1141
1460
  }
1142
1461
 
1462
+ // Last resort, CLIENT-SIDE only: the distinct values present in the loaded
1463
+ // rows. Precedence is deliberate — explicit `filterOptions`, then the
1464
+ // server's `filterValues`, then what the data itself says. See
1465
+ // `derivedFilterOptions` for why it derives from the unfiltered row set.
1466
+ const derived = derivedFilterOptions.value[field.key];
1467
+ if (derived && derived.length > 0) {
1468
+ return [...options, ...derived];
1469
+ }
1470
+
1143
1471
  // No values to choose from — don't offer a lone "All x" that filters nothing.
1144
1472
  return [];
1145
1473
  };
@@ -1151,7 +1479,7 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1151
1479
  // Returns null when there is nothing stable to key on — every read and write
1152
1480
  // guards on that, so a keyless table simply doesn't persist.
1153
1481
  const perPageStorageKey = computed<string | null>(() => {
1154
- const url = props.inertiaUrl || props.apiUrl;
1482
+ const url = resolvedInertiaUrl.value || resolvedApiUrl.value;
1155
1483
  if (!url) return null;
1156
1484
  return `dxtable-perpage-${url.replace(/\//g, '-')}`;
1157
1485
  });
@@ -1232,7 +1560,7 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1232
1560
  } else if (hasInertiaUrl.value && isInertiaMode.value && router) {
1233
1561
  // Inertia mode - trigger navigation with new filters
1234
1562
  router.get(
1235
- props.inertiaUrl!,
1563
+ resolvedInertiaUrl.value!,
1236
1564
  {
1237
1565
  page: 1, // Reset to first page when filters change
1238
1566
  filters: props.filters,
@@ -1248,8 +1576,15 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1248
1576
  // of BTable's provider context, so an api-url-only change doesn't re-invoke the
1249
1577
  // provider on its own — without this the table keeps showing the previous url's
1250
1578
  // rows until some other trigger (sort, per-page, create/edit/delete) fires.
1251
- watch(() => props.apiUrl, (newUrl, oldUrl) => {
1252
- if (newUrl !== oldUrl && isProviderMode.value) {
1579
+ watch(() => resolvedApiUrl.value, (newUrl, oldUrl) => {
1580
+ // `newUrl` must be truthy: an api-url → provider transition drops the url to
1581
+ // `undefined` while still satisfying `isProviderMode` via the new provider.
1582
+ // Without this guard both this watcher and the provider watcher below run
1583
+ // their clear+refresh on that transition (bvn coalesces the two refreshes
1584
+ // into one fetch, so it isn't an observable double-fetch — but the redundant
1585
+ // work is avoidable). The provider watcher owns the url-removal transition;
1586
+ // this one only fires for an actual new url.
1587
+ if (newUrl && newUrl !== oldUrl && isProviderMode.value) {
1253
1588
  // Clear cached filter values and pagination when API endpoint changes.
1254
1589
  // The next provider call will request fresh filter values.
1255
1590
  apiFilterValues.value = {};
@@ -1268,6 +1603,25 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
1268
1603
  }
1269
1604
  });
1270
1605
 
1606
+ // A `provider` swap (`:provider` or `source.provider` reassigned to a new
1607
+ // function) must refetch, exactly as an `apiUrl` swap does above. `effectiveProvider`'s
1608
+ // identity is deliberately STABLE (#82) so BTable doesn't refetch on its own
1609
+ // when the underlying provider changes — so, like the apiUrl watcher, force it.
1610
+ // Only fires for an actual provider function (apiUrl mode is covered above), and
1611
+ // `watch` skips the initial value, so no double-fetch on mount.
1612
+ watch(() => resolvedProvider.value, (newProvider, oldProvider) => {
1613
+ if (newProvider !== oldProvider && !!newProvider) {
1614
+ apiFilterValues.value = {};
1615
+ apiPaginationMeta.value = null;
1616
+ apiError.value = null;
1617
+ const wasOnFirstPage = apiCurrentPage.value === 1;
1618
+ apiCurrentPage.value = 1;
1619
+ if (wasOnFirstPage) {
1620
+ refresh();
1621
+ }
1622
+ }
1623
+ });
1624
+
1271
1625
  // Computed effective perPage (use external if provided, otherwise internal)
1272
1626
  const effectivePerPage = computed(() => {
1273
1627
  // Only when the consumer is actually driving it (v-model). A bare
@@ -1330,7 +1684,7 @@ const clientSidePaginatedItems = computed<T[]>(() => {
1330
1684
  // Client-side pagination metadata
1331
1685
  const clientSidePagination = computed<PaginationData>(() => {
1332
1686
  const total = clientSideFilteredItems.value.length;
1333
- const totalUnfiltered = props.items?.length || 0;
1687
+ const totalUnfiltered = resolvedItems.value?.length || 0;
1334
1688
  const perPage = effectivePerPage.value;
1335
1689
  const lastPage = clientSideLastPage.value;
1336
1690
 
@@ -1389,7 +1743,12 @@ const shouldShowPerPageSelector = computed(() => {
1389
1743
  return total >= smallestOption;
1390
1744
  });
1391
1745
 
1392
- // Detect which fields need server filter values
1746
+ // Detect which fields need server filter values.
1747
+ //
1748
+ // Client-side derived options (`derivedFilterOptions`) deliberately do NOT
1749
+ // shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
1750
+ // and `isProviderMode` requires `!resolvedClientSide` — the two modes are
1751
+ // mutually exclusive, so a client-side derived column can never appear here.
1393
1752
  const fieldsNeedingFilterValues = computed(() => {
1394
1753
  return props.fields
1395
1754
  .filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
@@ -1426,7 +1785,7 @@ const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
1426
1785
  // catch: `effectiveProvider` wraps every provider (including a consumer's own)
1427
1786
  // in one error handler, so no provider can fail silently.
1428
1787
  const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1429
- if (!props.apiUrl) return [];
1788
+ if (!resolvedApiUrl.value) return [];
1430
1789
 
1431
1790
  const params: any = {
1432
1791
  page: context.currentPage,
@@ -1448,7 +1807,7 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1448
1807
  ? props.apiAdapter.request(params)
1449
1808
  : params;
1450
1809
 
1451
- const response = await api.get<any>(props.apiUrl, wireParams);
1810
+ const response = await api.get<any>(resolvedApiUrl.value, wireParams);
1452
1811
 
1453
1812
  // Adapt a foreign response envelope back into the dfl shape. Gets the
1454
1813
  // ORIGINAL dfl-shape params (page/perPage/…) for context, since a foreign
@@ -1485,14 +1844,24 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1485
1844
  * a blank page to a user. A consumer's provider used to be the silent case: the
1486
1845
  * error handling lived inside the internal one, so it never covered them.
1487
1846
  */
1847
+ // Monotonic generation for provider calls. When a provider is swapped (or a
1848
+ // refetch supersedes an in-flight call), a still-pending older call that later
1849
+ // REJECTS must not publish its error over the newer call's rows — bvn already
1850
+ // discards a superseded call's rows, but `apiError` isn't otherwise guarded.
1851
+ let providerGeneration = 0;
1852
+
1488
1853
  const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1489
- const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
1854
+ const provider = resolvedProvider.value || (resolvedApiUrl.value ? internalProvider : undefined);
1490
1855
  if (!provider) return [];
1491
1856
 
1857
+ const generation = ++providerGeneration;
1492
1858
  apiError.value = null;
1493
1859
  try {
1494
1860
  return await provider(context);
1495
1861
  } catch (error: any) {
1862
+ // Superseded by a newer provider call — its result/error owns the table
1863
+ // now, so drop this stale rejection silently.
1864
+ if (generation !== providerGeneration) return [];
1496
1865
  // eslint-disable-next-line no-console
1497
1866
  console.error('DXTable: the data provider failed', error);
1498
1867
  // `error.message` covers the internal provider's ApiError; the
@@ -1512,14 +1881,14 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
1512
1881
  // function changes, which would double every request that the explicit apiUrl
1513
1882
  // watcher already refetches (#82).
1514
1883
  const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
1515
- props.provider || props.apiUrl ? wrappedProvider : undefined,
1884
+ resolvedProvider.value || resolvedApiUrl.value ? wrappedProvider : undefined,
1516
1885
  );
1517
1886
 
1518
1887
  const handlePageChange = (page: number) => {
1519
1888
  // If inertiaUrl provided, handle navigation automatically
1520
1889
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1521
1890
  router.get(
1522
- props.inertiaUrl!,
1891
+ resolvedInertiaUrl.value!,
1523
1892
  {
1524
1893
  page,
1525
1894
  filters: effectiveFilters.value,
@@ -1574,7 +1943,7 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
1574
1943
  return { items: clientSidePaginatedItems.value, noLocalSorting: true };
1575
1944
  }
1576
1945
  // inertia
1577
- return { items: props.items, noLocalSorting: true, busy: effectiveBusy.value };
1946
+ return { items: resolvedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
1578
1947
  });
1579
1948
 
1580
1949
  /*
@@ -1627,7 +1996,7 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
1627
1996
  // The cleared state has a key but no order — `sortParams` sends nothing
1628
1997
  // for it, rather than inventing an ascending sort the user didn't ask for.
1629
1998
  router.get(
1630
- props.inertiaUrl!,
1999
+ resolvedInertiaUrl.value!,
1631
2000
  {
1632
2001
  page: props.pagination?.current_page || 1,
1633
2002
  filters: effectiveFilters.value,
@@ -1684,7 +2053,7 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1684
2053
  // Handle navigation automatically
1685
2054
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1686
2055
  router.get(
1687
- props.inertiaUrl!,
2056
+ resolvedInertiaUrl.value!,
1688
2057
  {
1689
2058
  page: 1, // Reset to first page when changing perPage
1690
2059
  filters: effectiveFilters.value,
@@ -1737,7 +2106,7 @@ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
1737
2106
  // Handle Inertia navigation automatically if URL provided
1738
2107
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1739
2108
  router.get(
1740
- props.inertiaUrl!,
2109
+ resolvedInertiaUrl.value!,
1741
2110
  {
1742
2111
  page: 1, // Reset to first page when filtering
1743
2112
  filters: newFilters,
@@ -1769,7 +2138,7 @@ const refresh = () => {
1769
2138
  (tableRef.value as any).refresh();
1770
2139
  }
1771
2140
  // Inertia mode: reload current page to refresh data
1772
- else if (isInertiaMode.value && props.inertiaUrl && router) {
2141
+ else if (isInertiaMode.value && resolvedInertiaUrl.value && router) {
1773
2142
  router.reload();
1774
2143
  }
1775
2144
  };
@@ -1786,6 +2155,12 @@ const refresh = () => {
1786
2155
  * - `head(<key>)` — DXTable draws its own column headers (sort indicators,
1787
2156
  * field hints). Forwarding a consumer's would silently drop those.
1788
2157
  * - `thead-top` — DXTable renders the inline filter row there.
2158
+ *
2159
+ * `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
2160
+ * not forwarded: the inner table has no such slot, and the prefixes below are
2161
+ * matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
2162
+ * `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
2163
+ * (which filters on `isTableSlot`), so adding one never remounts the table.
1789
2164
  */
1790
2165
  const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
1791
2166
  const TABLE_SLOT_NAMES = new Set([