@omnitend/dashboard-for-laravel 0.33.0 → 0.34.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 (50) 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/DXNumericField.vue.d.ts +23 -0
  14. package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
  15. package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
  16. package/dist/components/extended/DXTable.vue.d.ts +91 -2
  17. package/dist/components/extended/DXTableEditorModal.vue.d.ts +17 -2
  18. package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
  19. package/dist/composables/useAsyncOptions.d.ts +20 -0
  20. package/dist/composables/useResourceEditor.d.ts +2 -1
  21. package/dist/dashboard-for-laravel.js +9987 -10054
  22. package/dist/dashboard-for-laravel.js.map +1 -1
  23. package/dist/dashboard-for-laravel.umd.cjs +7 -7
  24. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  25. package/dist/index.d.ts +3 -2
  26. package/dist/style.css +1 -1
  27. package/dist/types/index.d.ts +146 -0
  28. package/dist/utils/formSchema.d.ts +59 -0
  29. package/docs/public/api-reference.json +464 -30
  30. package/docs/public/docs-map.md +1 -1
  31. package/docs/public/llms.txt +5 -2
  32. package/package.json +1 -1
  33. package/resources/js/components/charts/DXBarChart.vue +4 -43
  34. package/resources/js/components/charts/DXLineChart.vue +4 -43
  35. package/resources/js/components/charts/useThemedChart.ts +69 -0
  36. package/resources/js/components/extended/DXChoiceField.vue +114 -0
  37. package/resources/js/components/extended/DXField.vue +328 -615
  38. package/resources/js/components/extended/DXFieldShell.vue +124 -0
  39. package/resources/js/components/extended/DXForm.vue +9 -5
  40. package/resources/js/components/extended/DXNumericField.vue +144 -0
  41. package/resources/js/components/extended/DXRepeater.vue +53 -175
  42. package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
  43. package/resources/js/components/extended/DXTable.vue +250 -37
  44. package/resources/js/components/extended/DXTableEditorModal.vue +34 -2
  45. package/resources/js/composables/defineForm.ts +8 -44
  46. package/resources/js/composables/useAsyncOptions.ts +61 -0
  47. package/resources/js/composables/useResourceEditor.ts +24 -38
  48. package/resources/js/index.ts +33 -0
  49. package/resources/js/types/index.ts +231 -0
  50. package/resources/js/utils/formSchema.ts +126 -0
@@ -0,0 +1,133 @@
1
+ <!--
2
+ @component
3
+ DXRepeaterCards — the cards layout for DXRepeater: each row is its own
4
+ bordered card with sub-fields stacked vertically, plus the add-row button.
5
+
6
+ Internal to DXRepeater (not exported). It exists because DXRepeater rendered
7
+ this exact markup TWICE — as the default `repeaterLayout: "cards"` branch and
8
+ as the responsive fallback when the `"table"` layout lacks room — and the two
9
+ copies would drift (#135). DXRepeater owns all the row state and passes it in;
10
+ this component is presentational. The `.dx-repeater-row*` scoped styles live
11
+ here (a scoped rule can't reach markup in another component), so the cards
12
+ chrome moves as one unit with the markup it styles.
13
+ -->
14
+ <template>
15
+ <div class="dx-repeater">
16
+ <div
17
+ v-for="(entry, position) in visibleRows"
18
+ :key="rowKey(entry.row, position)"
19
+ class="dx-repeater-row"
20
+ >
21
+ <!--
22
+ @slot Custom layout for a single repeater row, replacing the default sub-field stack (including the built-in Remove control — call `remove()` yourself).
23
+ @binding {object} row The current row's data object.
24
+ @binding {number} index The row's zero-based position among visible rows.
25
+ @binding {FieldDefinition[]} fields The sub-field definitions for the row.
26
+ @binding {function} remove Removes this row (respects `minItems`; soft-deletes instead of splicing when `field.softDeleteKey` is set and the row has an `id`).
27
+ @binding {string} path The dot path into `form.data` for this row (e.g. `lines.0`).
28
+ -->
29
+ <slot
30
+ v-if="$slots.row"
31
+ name="row"
32
+ :row="entry.row"
33
+ :index="position"
34
+ :fields="subFields"
35
+ :remove="() => removeRow(entry.index)"
36
+ :path="rowPath(entry.index)"
37
+ />
38
+
39
+ <!-- Default row: stack each sub-field -->
40
+ <template v-else>
41
+ <div class="dx-repeater-row-header">
42
+ <span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
43
+ <DButton
44
+ variant="outline-danger"
45
+ size="sm"
46
+ :disabled="visibleRows.length <= minItems"
47
+ @click="removeRow(entry.index)"
48
+ >
49
+ Remove
50
+ </DButton>
51
+ </div>
52
+ <DXField
53
+ v-for="subField in subFields"
54
+ :key="subField.key"
55
+ :field="subField"
56
+ :form="form"
57
+ :model="entry.row"
58
+ :key-path="`${rowPath(entry.index)}.${subField.key}`"
59
+ :error-key="`${rowPath(entry.index)}.${subField.key}`"
60
+ />
61
+ </template>
62
+ </div>
63
+
64
+ <DButton
65
+ variant="outline-primary"
66
+ size="sm"
67
+ :disabled="maxItems !== undefined && visibleRows.length >= maxItems"
68
+ @click="addRow"
69
+ >
70
+ {{ field.addLabel || "Add" }}
71
+ </DButton>
72
+ </div>
73
+ </template>
74
+
75
+ <script setup lang="ts">
76
+ import DButton from "../base/DButton.vue";
77
+ import DXField from "./DXField.vue";
78
+ import type { UseFormReturn } from "../../composables/useForm";
79
+ import type { FieldDefinition } from "../../types";
80
+
81
+ interface Props {
82
+ /** Rows shown in the UI, each paired with its real (unfiltered) array index. */
83
+ visibleRows: Array<{ row: any; index: number }>;
84
+ /** Sub-field definitions for a row. */
85
+ subFields: FieldDefinition[];
86
+ /** Form instance owning the repeater array. */
87
+ form: UseFormReturn<any>;
88
+ /** The repeater field definition (for `showRowIndex` and `addLabel`). */
89
+ field: FieldDefinition;
90
+ /** Minimum rows — the Remove button disables at this count. */
91
+ minItems: number;
92
+ /** Maximum rows — the Add button disables at this count. */
93
+ maxItems: number | undefined;
94
+ /** Stable v-for key for a row (owned by DXRepeater — row identity, not index). */
95
+ rowKey: (row: any, fallbackPosition: number) => string | number;
96
+ /** Dot path into form.data for a row at the given true index. */
97
+ rowPath: (index: number) => string;
98
+ /** Remove the row at the given true index (respects minItems / soft-delete). */
99
+ removeRow: (index: number) => void;
100
+ /** Append a fresh row (respects maxItems). */
101
+ addRow: () => void;
102
+ }
103
+
104
+ defineProps<Props>();
105
+ </script>
106
+
107
+ <style scoped>
108
+ .dx-repeater-row {
109
+ border: 1px solid var(--bs-border-color);
110
+ border-radius: var(--bs-border-radius);
111
+ padding: 1rem;
112
+ margin-bottom: 0.75rem;
113
+ }
114
+
115
+ .dx-repeater-row-header {
116
+ display: flex;
117
+ align-items: center;
118
+ margin-bottom: 0.75rem;
119
+ }
120
+
121
+ /* The Remove control is always this header's last child, whether or not the
122
+ (opt-in) row-index span is also present — push it to the end explicitly
123
+ rather than relying on `justify-content: space-between`, which centres a
124
+ single remaining child at the *start* once the index is off (the default). */
125
+ .dx-repeater-row-header > :last-child {
126
+ margin-inline-start: auto;
127
+ }
128
+
129
+ .dx-repeater-row-index {
130
+ font-weight: 600;
131
+ color: var(--bs-secondary-color);
132
+ }
133
+ </style>
@@ -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"
@@ -239,6 +239,13 @@
239
239
  :is-create-mode="isCreateMode"
240
240
  :pending-action="pendingAction"
241
241
  :delete-url="deleteUrl"
242
+ :layout="editLayout"
243
+ :label-cols="editLabelCols"
244
+ :card="editCard"
245
+ :item-name="itemName"
246
+ :save-text="saveText"
247
+ :create-text="createText"
248
+ :delete-text="deleteText"
242
249
  @save="handleEditSave"
243
250
  @cancel="handleEditCancel"
244
251
  @delete="handleDelete"
@@ -266,6 +273,7 @@ import { api } from "../../utils/api";
266
273
  import pluralize from "pluralize";
267
274
  import { useResourceEditor } from "../../composables/useResourceEditor";
268
275
  import { getByPath } from "../../utils/objectPath";
276
+ import type { FieldDefinition, LabelCols } from "../../types";
269
277
  import DContainer from "../base/DContainer.vue";
270
278
  import DRow from "../base/DRow.vue";
271
279
  import DCol from "../base/DCol.vue";
@@ -284,6 +292,23 @@ export interface FilterOption {
284
292
  text: string;
285
293
  }
286
294
 
295
+ /**
296
+ * Adapts the built-in `apiUrl` provider's request/response to a backend whose
297
+ * convention differs from dfl's (spatie query-builder params, an envelope
298
+ * without the `{data, pagination}` shape). The sanctioned replacement for the
299
+ * axios-interceptor bridges #132 removed the route for: `request` maps the
300
+ * outgoing params (its return is what goes on the wire); `response` maps the
301
+ * body back, receiving the ORIGINAL dfl-shape params (`page`, `perPage`, …)
302
+ * so it can synthesize paginator metadata a foreign envelope lacks.
303
+ */
304
+ export interface DXTableApiAdapter {
305
+ request?: (params: Record<string, any>) => Record<string, any>;
306
+ response?: (
307
+ body: any,
308
+ context: { params: Record<string, any> },
309
+ ) => { data: any[]; pagination?: PaginationData; filterValues?: Record<string, string[]> };
310
+ }
311
+
287
312
  export interface TableField {
288
313
  key: string;
289
314
  label?: string;
@@ -398,6 +423,27 @@ export interface EditTab {
398
423
  lazy?: boolean;
399
424
  }
400
425
 
426
+ /**
427
+ * Discriminated data-source descriptor (#130). The additive, type-safe
428
+ * replacement for the legacy `items` / `provider` / `apiUrl` / `inertiaUrl` /
429
+ * `clientSide` prop matrix, in which every prop was independently optional and
430
+ * the mode was inferred by precedence — so invalid combinations compiled and
431
+ * some were silently ignored.
432
+ *
433
+ * Pass ONE `source`: the mode is explicit and its required companion prop is
434
+ * enforced by the compiler (`{ mode: 'provider' }` without a `provider` won't
435
+ * type-check). The legacy props still work and take over when `source` is
436
+ * omitted; a future major removes them with a codemod (#130 Phase 3).
437
+ *
438
+ * `apiAdapter` (transport translation) and `pagination` (server-supplied page
439
+ * metadata) stay sibling props — they're orthogonal to which mode is active.
440
+ */
441
+ export type DXTableSource<TItem = any> =
442
+ | { mode: 'client'; items: TItem[] }
443
+ | { mode: 'provider'; provider: BTableProvider<TItem> }
444
+ | { mode: 'api'; url: string }
445
+ | { mode: 'inertia'; items: TItem[]; url?: string };
446
+
401
447
  export interface Props<TItem = any> {
402
448
  /** Table title */
403
449
  title?: string;
@@ -414,6 +460,18 @@ export interface Props<TItem = any> {
414
460
  /** API endpoint URL for auto-provider mode (alternative to provider function) */
415
461
  apiUrl?: string;
416
462
 
463
+ /** Adapts the built-in provider's request params / response body to a
464
+ * backend convention that differs from dfl's (see DXTableApiAdapter). */
465
+ apiAdapter?: DXTableApiAdapter;
466
+
467
+ /**
468
+ * Discriminated data-source (#130) — the type-safe alternative to the
469
+ * legacy `items` / `provider` / `apiUrl` / `inertiaUrl` / `clientSide`
470
+ * props. When set it WINS over those props; when omitted, the legacy props
471
+ * apply unchanged. See `DXTableSource`.
472
+ */
473
+ source?: DXTableSource<TItem>;
474
+
417
475
  /** Table field definitions */
418
476
  fields: TableField[];
419
477
 
@@ -505,8 +563,12 @@ export interface Props<TItem = any> {
505
563
  card?: boolean;
506
564
 
507
565
  // Edit Modal Props
508
- /** Form field definitions for edit modal (if provided, enables edit on row click) */
509
- editFields?: any[]; // FieldDefinition[] - using any to avoid circular import
566
+ /**
567
+ * Form field definitions for edit modal (if provided, enables edit on row
568
+ * click). `import type` is erased at compile time, so this creates no
569
+ * runtime import cycle (#131).
570
+ */
571
+ editFields?: FieldDefinition[];
510
572
 
511
573
  /** Tab definitions for organizing edit modal content */
512
574
  editTabs?: EditTab[];
@@ -517,6 +579,38 @@ export interface Props<TItem = any> {
517
579
  /** Edit modal size */
518
580
  editModalSize?: 'sm' | 'md' | 'lg' | 'xl';
519
581
 
582
+ /**
583
+ * Field layout for the edit/create modal's form, forwarded to its `DXForm`
584
+ * ("vertical" = label above input, "horizontal" = label left). Defaults to
585
+ * **"horizontal"** to match the Omni Tend form convention (a raw modal form
586
+ * previously fell back to DXForm's vertical default, which looked out of
587
+ * place next to horizontal page forms). Pass `"vertical"` to opt out.
588
+ */
589
+ editLayout?: 'vertical' | 'horizontal';
590
+
591
+ /**
592
+ * Label column width for the edit modal's horizontal layout, forwarded to
593
+ * `DXForm`'s `labelCols` (a single width or a per-breakpoint object). Only
594
+ * meaningful when `editLayout` is "horizontal".
595
+ */
596
+ editLabelCols?: LabelCols;
597
+
598
+ /**
599
+ * Wrap the edit/create modal's form in a bordered card. Off by default (the
600
+ * modal body already provides a boundary); opt in for a more contained,
601
+ * panelled look around a tabbed edit form.
602
+ */
603
+ editCard?: boolean;
604
+
605
+ /** Override the modal's Save button label (default: "Save {item}"). */
606
+ saveText?: string;
607
+
608
+ /** Override the modal's Create button label (default: "Create {item}"). */
609
+ createText?: string;
610
+
611
+ /** Override the modal's Delete button label (default: "Delete {item}"). */
612
+ deleteText?: string;
613
+
520
614
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
521
615
  editUrl?: string;
522
616
 
@@ -642,6 +736,8 @@ const props = withDefaults(defineProps<Props<T>>(), {
642
736
  columnSize: "12",
643
737
  card: true,
644
738
  editModalSize: "lg",
739
+ editLayout: "horizontal",
740
+ editCard: false,
645
741
  showCreateButton: true,
646
742
  footClone: false,
647
743
  showEmpty: true,
@@ -683,15 +779,74 @@ const emit = defineEmits<{
683
779
  'update:busy': [busy: boolean];
684
780
  }>();
685
781
 
782
+ // Effective data-source values (#130). Prefer an explicit `source` prop, else
783
+ // fall back to the legacy `items`/`provider`/`apiUrl`/`inertiaUrl`/`clientSide`
784
+ // props. Every read below (mode detection, provider, client-side pipeline,
785
+ // inertia navigation) goes through these, so both APIs drive identical logic.
786
+ const resolvedClientSide = computed(() =>
787
+ props.source ? props.source.mode === 'client' : props.clientSide === true,
788
+ );
789
+ const resolvedItems = computed<T[] | undefined>(() => {
790
+ if (props.source) {
791
+ return props.source.mode === 'client' || props.source.mode === 'inertia'
792
+ ? props.source.items
793
+ : undefined;
794
+ }
795
+ return props.items;
796
+ });
797
+ const resolvedProvider = computed<BTableProvider<T> | undefined>(() =>
798
+ props.source
799
+ ? props.source.mode === 'provider'
800
+ ? props.source.provider
801
+ : undefined
802
+ : props.provider,
803
+ );
804
+ const resolvedApiUrl = computed<string | undefined>(() =>
805
+ props.source
806
+ ? props.source.mode === 'api'
807
+ ? props.source.url
808
+ : undefined
809
+ : props.apiUrl,
810
+ );
811
+ const resolvedInertiaUrl = computed<string | undefined>(() =>
812
+ props.source
813
+ ? props.source.mode === 'inertia'
814
+ ? props.source.url
815
+ : undefined
816
+ : props.inertiaUrl,
817
+ );
818
+
686
819
  // Mode detection
687
- const isProviderMode = computed(() => !props.clientSide && (!!props.provider || !!props.apiUrl));
688
- const isInertiaMode = computed(() => !props.clientSide && !props.provider && !props.apiUrl && !!props.items);
689
- const isClientSideMode = computed(() => props.clientSide === true && !!props.items);
690
- const hasInertiaUrl = computed(() => !!props.inertiaUrl);
691
-
692
- // Warn about invalid prop combinations in client-side mode
693
- if (props.clientSide && (props.apiUrl || props.inertiaUrl)) {
694
- console.warn('[DXTable] clientSide mode ignores apiUrl and inertiaUrl props. Data is processed locally from items.');
820
+ const isProviderMode = computed(() => !resolvedClientSide.value && (!!resolvedProvider.value || !!resolvedApiUrl.value));
821
+ const isInertiaMode = computed(() => !resolvedClientSide.value && !resolvedProvider.value && !resolvedApiUrl.value && !!resolvedItems.value);
822
+ const isClientSideMode = computed(() => resolvedClientSide.value && !!resolvedItems.value);
823
+ const hasInertiaUrl = computed(() => !!resolvedInertiaUrl.value);
824
+
825
+ // The active data-source mode, folded into the table's `:key` so a runtime
826
+ // mode switch (e.g. `source` provider → client) REMOUNTS the DTable. bvn's
827
+ // BTable caches its provider-fetched items internally and doesn't cleanly adopt
828
+ // a fresh `:items` binding when the provider is removed — without a remount the
829
+ // table shows "No items found" after switching to client/inertia mode. Stable
830
+ // for a table that never changes mode, so it adds no spurious remounts.
831
+ const activeMode = computed(() =>
832
+ isClientSideMode.value
833
+ ? 'client'
834
+ : isInertiaMode.value
835
+ ? 'inertia'
836
+ : isProviderMode.value
837
+ ? 'provider'
838
+ : 'empty',
839
+ );
840
+
841
+ // Warn about invalid LEGACY prop combinations (a `source` prop makes these
842
+ // impossible at the type level, so it's exempt). #130: `clientSide + provider`
843
+ // was previously uncovered by this guard.
844
+ if (
845
+ !props.source &&
846
+ props.clientSide &&
847
+ (props.provider || props.apiUrl || props.inertiaUrl)
848
+ ) {
849
+ console.warn('[DXTable] clientSide mode ignores provider, apiUrl and inertiaUrl props. Data is processed locally from items.');
695
850
  }
696
851
 
697
852
  // Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
@@ -814,7 +969,7 @@ const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
814
969
 
815
970
  const providerPagination = computed<PaginationData | null>(() => {
816
971
  if (apiPaginationMeta.value) return apiPaginationMeta.value;
817
- if (props.provider && paginationWasProvided && props.pagination) {
972
+ if (resolvedProvider.value && paginationWasProvided && props.pagination) {
818
973
  return props.pagination;
819
974
  }
820
975
  return null;
@@ -829,7 +984,7 @@ const providerPagination = computed<PaginationData | null>(() => {
829
984
  const providerHasRun = ref(false);
830
985
  let warnedAboutProviderPagination = false;
831
986
  watch(
832
- () => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
987
+ () => [resolvedProvider.value, props.showPagination, providerPagination.value, providerHasRun.value] as const,
833
988
  ([provider, showPagination, pagination, hasRun]) => {
834
989
  if (warnedAboutProviderPagination || !hasRun) return;
835
990
  if (provider && showPagination && !pagination) {
@@ -852,16 +1007,16 @@ const clientSideCurrentPage = ref(1);
852
1007
 
853
1008
  // Client-side filtered items
854
1009
  const clientSideFilteredItems = computed<T[]>(() => {
855
- if (!isClientSideMode.value || !props.items) return [];
1010
+ if (!isClientSideMode.value || !resolvedItems.value) return [];
856
1011
 
857
1012
  const filters = effectiveFilters.value;
858
1013
  const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
859
1014
 
860
1015
  if (filterKeys.length === 0) {
861
- return props.items;
1016
+ return resolvedItems.value;
862
1017
  }
863
1018
 
864
- return props.items.filter(item => {
1019
+ return resolvedItems.value.filter(item => {
865
1020
  return filterKeys.every(key => {
866
1021
  // Resolve by the FILTER key, which may differ from the column's key.
867
1022
  const field = props.fields.find(f => filterKeyFor(f) === key);
@@ -1130,7 +1285,7 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1130
1285
  // Returns null when there is nothing stable to key on — every read and write
1131
1286
  // guards on that, so a keyless table simply doesn't persist.
1132
1287
  const perPageStorageKey = computed<string | null>(() => {
1133
- const url = props.inertiaUrl || props.apiUrl;
1288
+ const url = resolvedInertiaUrl.value || resolvedApiUrl.value;
1134
1289
  if (!url) return null;
1135
1290
  return `dxtable-perpage-${url.replace(/\//g, '-')}`;
1136
1291
  });
@@ -1211,7 +1366,7 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1211
1366
  } else if (hasInertiaUrl.value && isInertiaMode.value && router) {
1212
1367
  // Inertia mode - trigger navigation with new filters
1213
1368
  router.get(
1214
- props.inertiaUrl!,
1369
+ resolvedInertiaUrl.value!,
1215
1370
  {
1216
1371
  page: 1, // Reset to first page when filters change
1217
1372
  filters: props.filters,
@@ -1227,8 +1382,15 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1227
1382
  // of BTable's provider context, so an api-url-only change doesn't re-invoke the
1228
1383
  // provider on its own — without this the table keeps showing the previous url's
1229
1384
  // rows until some other trigger (sort, per-page, create/edit/delete) fires.
1230
- watch(() => props.apiUrl, (newUrl, oldUrl) => {
1231
- if (newUrl !== oldUrl && isProviderMode.value) {
1385
+ watch(() => resolvedApiUrl.value, (newUrl, oldUrl) => {
1386
+ // `newUrl` must be truthy: an api-url → provider transition drops the url to
1387
+ // `undefined` while still satisfying `isProviderMode` via the new provider.
1388
+ // Without this guard both this watcher and the provider watcher below run
1389
+ // their clear+refresh on that transition (bvn coalesces the two refreshes
1390
+ // into one fetch, so it isn't an observable double-fetch — but the redundant
1391
+ // work is avoidable). The provider watcher owns the url-removal transition;
1392
+ // this one only fires for an actual new url.
1393
+ if (newUrl && newUrl !== oldUrl && isProviderMode.value) {
1232
1394
  // Clear cached filter values and pagination when API endpoint changes.
1233
1395
  // The next provider call will request fresh filter values.
1234
1396
  apiFilterValues.value = {};
@@ -1247,6 +1409,25 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
1247
1409
  }
1248
1410
  });
1249
1411
 
1412
+ // A `provider` swap (`:provider` or `source.provider` reassigned to a new
1413
+ // function) must refetch, exactly as an `apiUrl` swap does above. `effectiveProvider`'s
1414
+ // identity is deliberately STABLE (#82) so BTable doesn't refetch on its own
1415
+ // when the underlying provider changes — so, like the apiUrl watcher, force it.
1416
+ // Only fires for an actual provider function (apiUrl mode is covered above), and
1417
+ // `watch` skips the initial value, so no double-fetch on mount.
1418
+ watch(() => resolvedProvider.value, (newProvider, oldProvider) => {
1419
+ if (newProvider !== oldProvider && !!newProvider) {
1420
+ apiFilterValues.value = {};
1421
+ apiPaginationMeta.value = null;
1422
+ apiError.value = null;
1423
+ const wasOnFirstPage = apiCurrentPage.value === 1;
1424
+ apiCurrentPage.value = 1;
1425
+ if (wasOnFirstPage) {
1426
+ refresh();
1427
+ }
1428
+ }
1429
+ });
1430
+
1250
1431
  // Computed effective perPage (use external if provided, otherwise internal)
1251
1432
  const effectivePerPage = computed(() => {
1252
1433
  // Only when the consumer is actually driving it (v-model). A bare
@@ -1309,7 +1490,7 @@ const clientSidePaginatedItems = computed<T[]>(() => {
1309
1490
  // Client-side pagination metadata
1310
1491
  const clientSidePagination = computed<PaginationData>(() => {
1311
1492
  const total = clientSideFilteredItems.value.length;
1312
- const totalUnfiltered = props.items?.length || 0;
1493
+ const totalUnfiltered = resolvedItems.value?.length || 0;
1313
1494
  const perPage = effectivePerPage.value;
1314
1495
  const lastPage = clientSideLastPage.value;
1315
1496
 
@@ -1405,7 +1586,7 @@ const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
1405
1586
  // catch: `effectiveProvider` wraps every provider (including a consumer's own)
1406
1587
  // in one error handler, so no provider can fail silently.
1407
1588
  const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1408
- if (!props.apiUrl) return [];
1589
+ if (!resolvedApiUrl.value) return [];
1409
1590
 
1410
1591
  const params: any = {
1411
1592
  page: context.currentPage,
@@ -1419,19 +1600,41 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1419
1600
  params.filterValues = fieldsNeedingFilterValues.value;
1420
1601
  }
1421
1602
 
1422
- const response = await api.get<any>(props.apiUrl, params);
1603
+ // Adapt the outgoing params to the backend's convention (e.g. spatie
1604
+ // query-builder's `sort=-name` / `filter[key]`). This is the sanctioned
1605
+ // replacement for the axios request interceptors #132 removed the route
1606
+ // for — the adapter's return IS the wire params.
1607
+ const wireParams = props.apiAdapter?.request
1608
+ ? props.apiAdapter.request(params)
1609
+ : params;
1610
+
1611
+ const response = await api.get<any>(resolvedApiUrl.value, wireParams);
1612
+
1613
+ // Adapt a foreign response envelope back into the dfl shape. Gets the
1614
+ // ORIGINAL dfl-shape params (page/perPage/…) for context, since a foreign
1615
+ // envelope often lacks paginator metadata the adapter must synthesize.
1616
+ let body = props.apiAdapter?.response
1617
+ ? props.apiAdapter.response(response.data, { params })
1618
+ : response.data;
1619
+
1620
+ // A backend that ignores the pagination convention and returns a bare
1621
+ // array of rows used to render as a SILENTLY EMPTY table (data present,
1622
+ // nothing shown). Degrade to visible rows with no pager metadata instead.
1623
+ if (Array.isArray(body)) {
1624
+ body = { data: body };
1625
+ }
1423
1626
 
1424
1627
  // Extract and store pagination metadata for display
1425
- if (response.data.pagination) {
1426
- apiPaginationMeta.value = response.data.pagination;
1628
+ if (body.pagination) {
1629
+ apiPaginationMeta.value = body.pagination;
1427
1630
  }
1428
1631
 
1429
1632
  // Extract and store filter values
1430
- if (response.data.filterValues) {
1431
- apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
1633
+ if (body.filterValues) {
1634
+ apiFilterValues.value = { ...apiFilterValues.value, ...body.filterValues };
1432
1635
  }
1433
1636
 
1434
- return response.data.data;
1637
+ return body.data;
1435
1638
  };
1436
1639
 
1437
1640
  /**
@@ -1442,14 +1645,24 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1442
1645
  * a blank page to a user. A consumer's provider used to be the silent case: the
1443
1646
  * error handling lived inside the internal one, so it never covered them.
1444
1647
  */
1648
+ // Monotonic generation for provider calls. When a provider is swapped (or a
1649
+ // refetch supersedes an in-flight call), a still-pending older call that later
1650
+ // REJECTS must not publish its error over the newer call's rows — bvn already
1651
+ // discards a superseded call's rows, but `apiError` isn't otherwise guarded.
1652
+ let providerGeneration = 0;
1653
+
1445
1654
  const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1446
- const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
1655
+ const provider = resolvedProvider.value || (resolvedApiUrl.value ? internalProvider : undefined);
1447
1656
  if (!provider) return [];
1448
1657
 
1658
+ const generation = ++providerGeneration;
1449
1659
  apiError.value = null;
1450
1660
  try {
1451
1661
  return await provider(context);
1452
1662
  } catch (error: any) {
1663
+ // Superseded by a newer provider call — its result/error owns the table
1664
+ // now, so drop this stale rejection silently.
1665
+ if (generation !== providerGeneration) return [];
1453
1666
  // eslint-disable-next-line no-console
1454
1667
  console.error('DXTable: the data provider failed', error);
1455
1668
  // `error.message` covers the internal provider's ApiError; the
@@ -1469,14 +1682,14 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
1469
1682
  // function changes, which would double every request that the explicit apiUrl
1470
1683
  // watcher already refetches (#82).
1471
1684
  const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
1472
- props.provider || props.apiUrl ? wrappedProvider : undefined,
1685
+ resolvedProvider.value || resolvedApiUrl.value ? wrappedProvider : undefined,
1473
1686
  );
1474
1687
 
1475
1688
  const handlePageChange = (page: number) => {
1476
1689
  // If inertiaUrl provided, handle navigation automatically
1477
1690
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1478
1691
  router.get(
1479
- props.inertiaUrl!,
1692
+ resolvedInertiaUrl.value!,
1480
1693
  {
1481
1694
  page,
1482
1695
  filters: effectiveFilters.value,
@@ -1531,7 +1744,7 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
1531
1744
  return { items: clientSidePaginatedItems.value, noLocalSorting: true };
1532
1745
  }
1533
1746
  // inertia
1534
- return { items: props.items, noLocalSorting: true, busy: effectiveBusy.value };
1747
+ return { items: resolvedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
1535
1748
  });
1536
1749
 
1537
1750
  /*
@@ -1584,7 +1797,7 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
1584
1797
  // The cleared state has a key but no order — `sortParams` sends nothing
1585
1798
  // for it, rather than inventing an ascending sort the user didn't ask for.
1586
1799
  router.get(
1587
- props.inertiaUrl!,
1800
+ resolvedInertiaUrl.value!,
1588
1801
  {
1589
1802
  page: props.pagination?.current_page || 1,
1590
1803
  filters: effectiveFilters.value,
@@ -1641,7 +1854,7 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1641
1854
  // Handle navigation automatically
1642
1855
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1643
1856
  router.get(
1644
- props.inertiaUrl!,
1857
+ resolvedInertiaUrl.value!,
1645
1858
  {
1646
1859
  page: 1, // Reset to first page when changing perPage
1647
1860
  filters: effectiveFilters.value,
@@ -1694,7 +1907,7 @@ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
1694
1907
  // Handle Inertia navigation automatically if URL provided
1695
1908
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1696
1909
  router.get(
1697
- props.inertiaUrl!,
1910
+ resolvedInertiaUrl.value!,
1698
1911
  {
1699
1912
  page: 1, // Reset to first page when filtering
1700
1913
  filters: newFilters,
@@ -1726,7 +1939,7 @@ const refresh = () => {
1726
1939
  (tableRef.value as any).refresh();
1727
1940
  }
1728
1941
  // Inertia mode: reload current page to refresh data
1729
- else if (isInertiaMode.value && props.inertiaUrl && router) {
1942
+ else if (isInertiaMode.value && resolvedInertiaUrl.value && router) {
1730
1943
  router.reload();
1731
1944
  }
1732
1945
  };