@omnitend/dashboard-for-laravel 0.33.1 → 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 +69 -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 +9982 -10052
  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 +458 -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 +202 -32
  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";
@@ -415,6 +423,27 @@ export interface EditTab {
415
423
  lazy?: boolean;
416
424
  }
417
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
+
418
447
  export interface Props<TItem = any> {
419
448
  /** Table title */
420
449
  title?: string;
@@ -435,6 +464,14 @@ export interface Props<TItem = any> {
435
464
  * backend convention that differs from dfl's (see DXTableApiAdapter). */
436
465
  apiAdapter?: DXTableApiAdapter;
437
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
+
438
475
  /** Table field definitions */
439
476
  fields: TableField[];
440
477
 
@@ -526,8 +563,12 @@ export interface Props<TItem = any> {
526
563
  card?: boolean;
527
564
 
528
565
  // 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
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[];
531
572
 
532
573
  /** Tab definitions for organizing edit modal content */
533
574
  editTabs?: EditTab[];
@@ -538,6 +579,38 @@ export interface Props<TItem = any> {
538
579
  /** Edit modal size */
539
580
  editModalSize?: 'sm' | 'md' | 'lg' | 'xl';
540
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
+
541
614
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
542
615
  editUrl?: string;
543
616
 
@@ -663,6 +736,8 @@ const props = withDefaults(defineProps<Props<T>>(), {
663
736
  columnSize: "12",
664
737
  card: true,
665
738
  editModalSize: "lg",
739
+ editLayout: "horizontal",
740
+ editCard: false,
666
741
  showCreateButton: true,
667
742
  footClone: false,
668
743
  showEmpty: true,
@@ -704,15 +779,74 @@ const emit = defineEmits<{
704
779
  'update:busy': [busy: boolean];
705
780
  }>();
706
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
+
707
819
  // 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.');
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.');
716
850
  }
717
851
 
718
852
  // Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
@@ -835,7 +969,7 @@ const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
835
969
 
836
970
  const providerPagination = computed<PaginationData | null>(() => {
837
971
  if (apiPaginationMeta.value) return apiPaginationMeta.value;
838
- if (props.provider && paginationWasProvided && props.pagination) {
972
+ if (resolvedProvider.value && paginationWasProvided && props.pagination) {
839
973
  return props.pagination;
840
974
  }
841
975
  return null;
@@ -850,7 +984,7 @@ const providerPagination = computed<PaginationData | null>(() => {
850
984
  const providerHasRun = ref(false);
851
985
  let warnedAboutProviderPagination = false;
852
986
  watch(
853
- () => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
987
+ () => [resolvedProvider.value, props.showPagination, providerPagination.value, providerHasRun.value] as const,
854
988
  ([provider, showPagination, pagination, hasRun]) => {
855
989
  if (warnedAboutProviderPagination || !hasRun) return;
856
990
  if (provider && showPagination && !pagination) {
@@ -873,16 +1007,16 @@ const clientSideCurrentPage = ref(1);
873
1007
 
874
1008
  // Client-side filtered items
875
1009
  const clientSideFilteredItems = computed<T[]>(() => {
876
- if (!isClientSideMode.value || !props.items) return [];
1010
+ if (!isClientSideMode.value || !resolvedItems.value) return [];
877
1011
 
878
1012
  const filters = effectiveFilters.value;
879
1013
  const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
880
1014
 
881
1015
  if (filterKeys.length === 0) {
882
- return props.items;
1016
+ return resolvedItems.value;
883
1017
  }
884
1018
 
885
- return props.items.filter(item => {
1019
+ return resolvedItems.value.filter(item => {
886
1020
  return filterKeys.every(key => {
887
1021
  // Resolve by the FILTER key, which may differ from the column's key.
888
1022
  const field = props.fields.find(f => filterKeyFor(f) === key);
@@ -1151,7 +1285,7 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1151
1285
  // Returns null when there is nothing stable to key on — every read and write
1152
1286
  // guards on that, so a keyless table simply doesn't persist.
1153
1287
  const perPageStorageKey = computed<string | null>(() => {
1154
- const url = props.inertiaUrl || props.apiUrl;
1288
+ const url = resolvedInertiaUrl.value || resolvedApiUrl.value;
1155
1289
  if (!url) return null;
1156
1290
  return `dxtable-perpage-${url.replace(/\//g, '-')}`;
1157
1291
  });
@@ -1232,7 +1366,7 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1232
1366
  } else if (hasInertiaUrl.value && isInertiaMode.value && router) {
1233
1367
  // Inertia mode - trigger navigation with new filters
1234
1368
  router.get(
1235
- props.inertiaUrl!,
1369
+ resolvedInertiaUrl.value!,
1236
1370
  {
1237
1371
  page: 1, // Reset to first page when filters change
1238
1372
  filters: props.filters,
@@ -1248,8 +1382,15 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
1248
1382
  // of BTable's provider context, so an api-url-only change doesn't re-invoke the
1249
1383
  // provider on its own — without this the table keeps showing the previous url's
1250
1384
  // rows until some other trigger (sort, per-page, create/edit/delete) fires.
1251
- watch(() => props.apiUrl, (newUrl, oldUrl) => {
1252
- 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) {
1253
1394
  // Clear cached filter values and pagination when API endpoint changes.
1254
1395
  // The next provider call will request fresh filter values.
1255
1396
  apiFilterValues.value = {};
@@ -1268,6 +1409,25 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
1268
1409
  }
1269
1410
  });
1270
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
+
1271
1431
  // Computed effective perPage (use external if provided, otherwise internal)
1272
1432
  const effectivePerPage = computed(() => {
1273
1433
  // Only when the consumer is actually driving it (v-model). A bare
@@ -1330,7 +1490,7 @@ const clientSidePaginatedItems = computed<T[]>(() => {
1330
1490
  // Client-side pagination metadata
1331
1491
  const clientSidePagination = computed<PaginationData>(() => {
1332
1492
  const total = clientSideFilteredItems.value.length;
1333
- const totalUnfiltered = props.items?.length || 0;
1493
+ const totalUnfiltered = resolvedItems.value?.length || 0;
1334
1494
  const perPage = effectivePerPage.value;
1335
1495
  const lastPage = clientSideLastPage.value;
1336
1496
 
@@ -1426,7 +1586,7 @@ const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
1426
1586
  // catch: `effectiveProvider` wraps every provider (including a consumer's own)
1427
1587
  // in one error handler, so no provider can fail silently.
1428
1588
  const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1429
- if (!props.apiUrl) return [];
1589
+ if (!resolvedApiUrl.value) return [];
1430
1590
 
1431
1591
  const params: any = {
1432
1592
  page: context.currentPage,
@@ -1448,7 +1608,7 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1448
1608
  ? props.apiAdapter.request(params)
1449
1609
  : params;
1450
1610
 
1451
- const response = await api.get<any>(props.apiUrl, wireParams);
1611
+ const response = await api.get<any>(resolvedApiUrl.value, wireParams);
1452
1612
 
1453
1613
  // Adapt a foreign response envelope back into the dfl shape. Gets the
1454
1614
  // ORIGINAL dfl-shape params (page/perPage/…) for context, since a foreign
@@ -1485,14 +1645,24 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1485
1645
  * a blank page to a user. A consumer's provider used to be the silent case: the
1486
1646
  * error handling lived inside the internal one, so it never covered them.
1487
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
+
1488
1654
  const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
1489
- const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
1655
+ const provider = resolvedProvider.value || (resolvedApiUrl.value ? internalProvider : undefined);
1490
1656
  if (!provider) return [];
1491
1657
 
1658
+ const generation = ++providerGeneration;
1492
1659
  apiError.value = null;
1493
1660
  try {
1494
1661
  return await provider(context);
1495
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 [];
1496
1666
  // eslint-disable-next-line no-console
1497
1667
  console.error('DXTable: the data provider failed', error);
1498
1668
  // `error.message` covers the internal provider's ApiError; the
@@ -1512,14 +1682,14 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
1512
1682
  // function changes, which would double every request that the explicit apiUrl
1513
1683
  // watcher already refetches (#82).
1514
1684
  const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
1515
- props.provider || props.apiUrl ? wrappedProvider : undefined,
1685
+ resolvedProvider.value || resolvedApiUrl.value ? wrappedProvider : undefined,
1516
1686
  );
1517
1687
 
1518
1688
  const handlePageChange = (page: number) => {
1519
1689
  // If inertiaUrl provided, handle navigation automatically
1520
1690
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1521
1691
  router.get(
1522
- props.inertiaUrl!,
1692
+ resolvedInertiaUrl.value!,
1523
1693
  {
1524
1694
  page,
1525
1695
  filters: effectiveFilters.value,
@@ -1574,7 +1744,7 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
1574
1744
  return { items: clientSidePaginatedItems.value, noLocalSorting: true };
1575
1745
  }
1576
1746
  // inertia
1577
- return { items: props.items, noLocalSorting: true, busy: effectiveBusy.value };
1747
+ return { items: resolvedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
1578
1748
  });
1579
1749
 
1580
1750
  /*
@@ -1627,7 +1797,7 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
1627
1797
  // The cleared state has a key but no order — `sortParams` sends nothing
1628
1798
  // for it, rather than inventing an ascending sort the user didn't ask for.
1629
1799
  router.get(
1630
- props.inertiaUrl!,
1800
+ resolvedInertiaUrl.value!,
1631
1801
  {
1632
1802
  page: props.pagination?.current_page || 1,
1633
1803
  filters: effectiveFilters.value,
@@ -1684,7 +1854,7 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1684
1854
  // Handle navigation automatically
1685
1855
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1686
1856
  router.get(
1687
- props.inertiaUrl!,
1857
+ resolvedInertiaUrl.value!,
1688
1858
  {
1689
1859
  page: 1, // Reset to first page when changing perPage
1690
1860
  filters: effectiveFilters.value,
@@ -1737,7 +1907,7 @@ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
1737
1907
  // Handle Inertia navigation automatically if URL provided
1738
1908
  if (hasInertiaUrl.value && isInertiaMode.value && router) {
1739
1909
  router.get(
1740
- props.inertiaUrl!,
1910
+ resolvedInertiaUrl.value!,
1741
1911
  {
1742
1912
  page: 1, // Reset to first page when filtering
1743
1913
  filters: newFilters,
@@ -1769,7 +1939,7 @@ const refresh = () => {
1769
1939
  (tableRef.value as any).refresh();
1770
1940
  }
1771
1941
  // Inertia mode: reload current page to refresh data
1772
- else if (isInertiaMode.value && props.inertiaUrl && router) {
1942
+ else if (isInertiaMode.value && resolvedInertiaUrl.value && router) {
1773
1943
  router.reload();
1774
1944
  }
1775
1945
  };
@@ -47,6 +47,9 @@
47
47
  :form="form"
48
48
  :fields="fields"
49
49
  :tabs="tabs"
50
+ :layout="layout"
51
+ :label-cols="labelCols"
52
+ :card="card"
50
53
  :card-tabs="false"
51
54
  :context="item ?? undefined"
52
55
  :show-submit="false"
@@ -147,7 +150,7 @@
147
150
  :disabled="form?.processing || loading"
148
151
  @click="emit('delete')"
149
152
  >
150
- Delete
153
+ {{ deleteLabel }}
151
154
  </DButton>
152
155
  </div>
153
156
  <div class="d-flex gap-2">
@@ -161,7 +164,7 @@
161
164
  :disabled="form?.processing || loading"
162
165
  @click="emit('save')"
163
166
  >
164
- {{ isCreateMode ? 'Create' : 'Save Changes' }}
167
+ {{ isCreateMode ? createLabel : saveLabel }}
165
168
  </DButton>
166
169
  </div>
167
170
  </div>
@@ -176,6 +179,7 @@ import DButton from "../base/DButton.vue";
176
179
  import DSpinner from "../base/DSpinner.vue";
177
180
  import DXForm from "./DXForm.vue";
178
181
  import type { EditTab } from "./DXTable.vue";
182
+ import type { LabelCols } from "../../types";
179
183
 
180
184
  interface Props {
181
185
  /** Whether the modal is open (v-model:show). */
@@ -204,10 +208,38 @@ interface Props {
204
208
  pendingAction: 'save' | 'delete' | null;
205
209
  /** Delete endpoint — gates the Delete button. */
206
210
  deleteUrl?: string;
211
+ /** Field layout forwarded to the modal's DXForm (defaults handled upstream). */
212
+ layout?: 'vertical' | 'horizontal';
213
+ /** Label column width forwarded to DXForm (horizontal layout). */
214
+ labelCols?: LabelCols;
215
+ /** Wrap the modal form in a card (opt-in — off by default). */
216
+ card?: boolean;
217
+ /** Singular item noun for the default button labels (e.g. "customer"). */
218
+ itemName?: string;
219
+ /** Override the Save button label (edit mode). */
220
+ saveText?: string;
221
+ /** Override the Create button label (create mode). */
222
+ createText?: string;
223
+ /** Override the Delete button label. */
224
+ deleteText?: string;
207
225
  }
208
226
 
209
227
  const props = defineProps<Props>();
210
228
 
229
+ // Title-case the singular item noun for button labels ("customer" → "Customer",
230
+ // "sales order" → "Sales Order") — capitalize the first letter of each word.
231
+ const capitalizedItem = computed(() => {
232
+ const noun = props.itemName?.trim() || 'item';
233
+ return noun.replace(/\b\w/g, (char) => char.toUpperCase());
234
+ });
235
+
236
+ // Buttons default to item-named copy ("Save Customer" / "Create Customer" /
237
+ // "Delete Customer") — clearer than a generic "Save Changes" — overridable per
238
+ // action. Falls back to "Save Item" etc. when no `itemName` is set.
239
+ const saveLabel = computed(() => props.saveText ?? `Save ${capitalizedItem.value}`);
240
+ const createLabel = computed(() => props.createText ?? `Create ${capitalizedItem.value}`);
241
+ const deleteLabel = computed(() => props.deleteText ?? `Delete ${capitalizedItem.value}`);
242
+
211
243
  const emit = defineEmits<{
212
244
  /** v-model:show — the modal open state changed. */
213
245
  'update:show': [value: boolean];