@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.
- 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 +91 -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 +9987 -10054
- 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 +464 -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 +250 -37
- 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";
|
|
@@ -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
|
-
/**
|
|
509
|
-
|
|
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(() => !
|
|
688
|
-
const isInertiaMode = computed(() => !
|
|
689
|
-
const isClientSideMode = computed(() =>
|
|
690
|
-
const hasInertiaUrl = computed(() => !!
|
|
691
|
-
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
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 (
|
|
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
|
-
() => [
|
|
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 || !
|
|
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
|
|
1016
|
+
return resolvedItems.value;
|
|
862
1017
|
}
|
|
863
1018
|
|
|
864
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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(() =>
|
|
1231
|
-
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
1426
|
-
apiPaginationMeta.value =
|
|
1628
|
+
if (body.pagination) {
|
|
1629
|
+
apiPaginationMeta.value = body.pagination;
|
|
1427
1630
|
}
|
|
1428
1631
|
|
|
1429
1632
|
// Extract and store filter values
|
|
1430
|
-
if (
|
|
1431
|
-
apiFilterValues.value = { ...apiFilterValues.value, ...
|
|
1633
|
+
if (body.filterValues) {
|
|
1634
|
+
apiFilterValues.value = { ...apiFilterValues.value, ...body.filterValues };
|
|
1432
1635
|
}
|
|
1433
1636
|
|
|
1434
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
1942
|
+
else if (isInertiaMode.value && resolvedInertiaUrl.value && router) {
|
|
1730
1943
|
router.reload();
|
|
1731
1944
|
}
|
|
1732
1945
|
};
|