@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.
- package/dist/charts.js +82 -90
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/base/DButton.vue.d.ts +1 -1
- package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
- package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
- package/dist/components/charts/useThemedChart.d.ts +35 -0
- package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
- package/dist/components/extended/DXField.vue.d.ts +10 -113
- package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
- package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
- package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
- package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
- package/dist/components/extended/DXTable.vue.d.ts +69 -2
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +17 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
- package/dist/composables/useAsyncOptions.d.ts +20 -0
- package/dist/composables/useResourceEditor.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +9982 -10052
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +146 -0
- package/dist/utils/formSchema.d.ts +59 -0
- package/docs/public/api-reference.json +458 -30
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +5 -2
- package/package.json +1 -1
- package/resources/js/components/charts/DXBarChart.vue +4 -43
- package/resources/js/components/charts/DXLineChart.vue +4 -43
- package/resources/js/components/charts/useThemedChart.ts +69 -0
- package/resources/js/components/extended/DXChoiceField.vue +114 -0
- package/resources/js/components/extended/DXField.vue +328 -615
- package/resources/js/components/extended/DXFieldShell.vue +124 -0
- package/resources/js/components/extended/DXForm.vue +9 -5
- package/resources/js/components/extended/DXNumericField.vue +144 -0
- package/resources/js/components/extended/DXRepeater.vue +53 -175
- package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
- package/resources/js/components/extended/DXTable.vue +202 -32
- package/resources/js/components/extended/DXTableEditorModal.vue +34 -2
- package/resources/js/composables/defineForm.ts +8 -44
- package/resources/js/composables/useAsyncOptions.ts +61 -0
- package/resources/js/composables/useResourceEditor.ts +24 -38
- package/resources/js/index.ts +33 -0
- package/resources/js/types/index.ts +231 -0
- 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
|
-
/**
|
|
530
|
-
|
|
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(() => !
|
|
709
|
-
const isInertiaMode = computed(() => !
|
|
710
|
-
const isClientSideMode = computed(() =>
|
|
711
|
-
const hasInertiaUrl = computed(() => !!
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
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 (
|
|
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
|
-
() => [
|
|
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 || !
|
|
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
|
|
1016
|
+
return resolvedItems.value;
|
|
883
1017
|
}
|
|
884
1018
|
|
|
885
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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(() =>
|
|
1252
|
-
|
|
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 =
|
|
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 (!
|
|
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>(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 ?
|
|
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];
|