@omnitend/dashboard-for-laravel 0.22.0 → 0.23.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/components/extended/DXDashboard.vue.d.ts +12 -1
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +7 -2
- package/dist/components/extended/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXTable.vue.d.ts +37 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +40 -0
- package/dist/dashboard-for-laravel.js +7992 -7883
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +23 -0
- package/docs/public/api-reference.json +120 -6
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -1
- package/resources/js/components/extended/DXDashboard.vue +42 -4
- package/resources/js/components/extended/DXDashboardNavbar.vue +5 -18
- package/resources/js/components/extended/DXField.vue +122 -26
- package/resources/js/components/extended/DXTable.vue +304 -133
- package/resources/js/components/extended/DXUserAvatar.vue +104 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +25 -0
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
<DRow class="justify-content-center">
|
|
11
11
|
<DCol :md="columnSize">
|
|
12
12
|
<DXTableShell :card="card">
|
|
13
|
-
<template v-if="title ||
|
|
13
|
+
<template v-if="title || showsCreateButton || $slots.header" #header>
|
|
14
14
|
<!-- @slot Card header content; overrides the default title heading and the "New {item}" button. -->
|
|
15
15
|
<slot name="header">
|
|
16
16
|
<div class="d-flex justify-content-between align-items-center">
|
|
17
17
|
<h4 class="mb-0">{{ title }}</h4>
|
|
18
18
|
<DButton
|
|
19
|
-
v-if="
|
|
19
|
+
v-if="showsCreateButton"
|
|
20
20
|
variant="primary"
|
|
21
21
|
size="sm"
|
|
22
22
|
@click="handleCreateNew"
|
|
@@ -27,15 +27,19 @@
|
|
|
27
27
|
</slot>
|
|
28
28
|
</template>
|
|
29
29
|
|
|
30
|
+
<!-- Rendered ABOVE the table, not instead of it: a failed
|
|
31
|
+
request is usually caused by the sort or a filter, and
|
|
32
|
+
replacing the table would take away the very controls
|
|
33
|
+
needed to undo it, leaving a reload as the only way out. -->
|
|
34
|
+
<div v-if="error || apiError" class="alert alert-danger">
|
|
35
|
+
{{ error || apiError }}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
30
38
|
<div v-if="effectiveBusy && !isProviderMode" class="text-center py-5">
|
|
31
39
|
<DSpinner variant="primary" />
|
|
32
40
|
<p class="mt-2">{{ loadingText }}</p>
|
|
33
41
|
</div>
|
|
34
42
|
|
|
35
|
-
<div v-else-if="error || apiError" class="alert alert-danger">
|
|
36
|
-
{{ error || apiError }}
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
43
|
<!-- Provider Mode: Use BTable's provider pattern -->
|
|
40
44
|
<DTable
|
|
41
45
|
v-else-if="isProviderMode"
|
|
@@ -51,7 +55,9 @@
|
|
|
51
55
|
:hover="hover"
|
|
52
56
|
:responsive="responsive"
|
|
53
57
|
:busy="busy"
|
|
58
|
+
v-bind="tablePassthroughProps"
|
|
54
59
|
@update:sort-by="handleSortChange"
|
|
60
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
55
61
|
@update:current-page="apiCurrentPage = $event"
|
|
56
62
|
@update:busy="handleBusyChange"
|
|
57
63
|
@row-clicked="handleRowClick"
|
|
@@ -119,23 +125,19 @@
|
|
|
119
125
|
</div>
|
|
120
126
|
</template>
|
|
121
127
|
|
|
122
|
-
<!--
|
|
128
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
129
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
130
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
131
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
123
132
|
<template
|
|
124
|
-
v-for="
|
|
133
|
+
v-for="name in forwardedTableSlotNames"
|
|
134
|
+
:key="name"
|
|
125
135
|
#[name]="slotProps"
|
|
126
136
|
>
|
|
127
137
|
<!--
|
|
128
|
-
@slot
|
|
129
|
-
@binding {object} item The row data object for this cell.
|
|
130
|
-
@binding {any} value The cell value for the field.
|
|
131
|
-
@binding {number} index The zero-based row index.
|
|
132
|
-
@binding {object} field The field definition for the column.
|
|
138
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
133
139
|
-->
|
|
134
|
-
<slot
|
|
135
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
136
|
-
:name="name"
|
|
137
|
-
v-bind="slotProps"
|
|
138
|
-
/>
|
|
140
|
+
<slot :name="name" v-bind="slotProps" />
|
|
139
141
|
</template>
|
|
140
142
|
</DTable>
|
|
141
143
|
|
|
@@ -151,7 +153,9 @@
|
|
|
151
153
|
:striped="striped"
|
|
152
154
|
:hover="hover"
|
|
153
155
|
:responsive="responsive"
|
|
156
|
+
v-bind="tablePassthroughProps"
|
|
154
157
|
@update:sort-by="handleSortChange"
|
|
158
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
155
159
|
@row-clicked="handleRowClick"
|
|
156
160
|
>
|
|
157
161
|
<!-- Inline Filter Row -->
|
|
@@ -217,23 +221,19 @@
|
|
|
217
221
|
</div>
|
|
218
222
|
</template>
|
|
219
223
|
|
|
220
|
-
<!--
|
|
224
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
225
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
226
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
227
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
221
228
|
<template
|
|
222
|
-
v-for="
|
|
229
|
+
v-for="name in forwardedTableSlotNames"
|
|
230
|
+
:key="name"
|
|
223
231
|
#[name]="slotProps"
|
|
224
232
|
>
|
|
225
233
|
<!--
|
|
226
|
-
@slot
|
|
227
|
-
@binding {object} item The row data object for this cell.
|
|
228
|
-
@binding {any} value The cell value for the field.
|
|
229
|
-
@binding {number} index The zero-based row index.
|
|
230
|
-
@binding {object} field The field definition for the column.
|
|
234
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
231
235
|
-->
|
|
232
|
-
<slot
|
|
233
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
234
|
-
:name="name"
|
|
235
|
-
v-bind="slotProps"
|
|
236
|
-
/>
|
|
236
|
+
<slot :name="name" v-bind="slotProps" />
|
|
237
237
|
</template>
|
|
238
238
|
</DTable>
|
|
239
239
|
|
|
@@ -250,7 +250,9 @@
|
|
|
250
250
|
:hover="hover"
|
|
251
251
|
:responsive="responsive"
|
|
252
252
|
:busy="effectiveBusy"
|
|
253
|
+
v-bind="tablePassthroughProps"
|
|
253
254
|
@update:sort-by="handleSortChange"
|
|
255
|
+
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
254
256
|
@row-clicked="handleRowClick"
|
|
255
257
|
>
|
|
256
258
|
<!-- Inline Filter Row -->
|
|
@@ -316,23 +318,19 @@
|
|
|
316
318
|
</div>
|
|
317
319
|
</template>
|
|
318
320
|
|
|
319
|
-
<!--
|
|
321
|
+
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
322
|
+
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
323
|
+
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
324
|
+
See `isTableSlot` for the two DXTable renders itself. -->
|
|
320
325
|
<template
|
|
321
|
-
v-for="
|
|
326
|
+
v-for="name in forwardedTableSlotNames"
|
|
327
|
+
:key="name"
|
|
322
328
|
#[name]="slotProps"
|
|
323
329
|
>
|
|
324
330
|
<!--
|
|
325
|
-
@slot
|
|
326
|
-
@binding {object} item The row data object for this cell.
|
|
327
|
-
@binding {any} value The cell value for the field.
|
|
328
|
-
@binding {number} index The zero-based row index.
|
|
329
|
-
@binding {object} field The field definition for the column.
|
|
331
|
+
@slot Any slot the underlying table supports, forwarded with its scope: `cell(<fieldKey>)` for a cell, `foot(<fieldKey>)` for a footer cell (needs `footClone`), `empty` for the no-rows message, `row-expansion` for expandable row detail, plus `custom-foot`, `top-row`, `bottom-row`, `thead-sub` and the `table-*` slots.
|
|
330
332
|
-->
|
|
331
|
-
<slot
|
|
332
|
-
v-if="typeof name === 'string' && name.startsWith('cell')"
|
|
333
|
-
:name="name"
|
|
334
|
-
v-bind="slotProps"
|
|
335
|
-
/>
|
|
333
|
+
<slot :name="name" v-bind="slotProps" />
|
|
336
334
|
</template>
|
|
337
335
|
</DTable>
|
|
338
336
|
|
|
@@ -499,9 +497,18 @@
|
|
|
499
497
|
<span>Loading…</span>
|
|
500
498
|
</div>
|
|
501
499
|
|
|
502
|
-
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise)
|
|
500
|
+
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise).
|
|
501
|
+
|
|
502
|
+
Keyed per modal open: the form OBJECT is created once and then
|
|
503
|
+
reseeded in place for each row, so without a key Vue reuses the
|
|
504
|
+
same DXField instances across records — and any per-field UI
|
|
505
|
+
state rides along with them. That leaked a revealed password
|
|
506
|
+
from one row into the next. The key gives each record a fresh
|
|
507
|
+
field subtree; the data still lives in `editForm`, so nothing
|
|
508
|
+
is lost by remounting. -->
|
|
503
509
|
<DXForm
|
|
504
510
|
v-if="editForm"
|
|
511
|
+
:key="editFormInstanceKey"
|
|
505
512
|
v-model:active-tab="activeTabIndex"
|
|
506
513
|
:form="editForm"
|
|
507
514
|
:fields="editFields"
|
|
@@ -628,7 +635,7 @@
|
|
|
628
635
|
</template>
|
|
629
636
|
|
|
630
637
|
<script setup lang="ts" generic="T = any">
|
|
631
|
-
import { computed, ref, watch, useSlots } from "vue";
|
|
638
|
+
import { computed, getCurrentInstance, ref, watch, useSlots } from "vue";
|
|
632
639
|
import { router } from "@inertiajs/vue3";
|
|
633
640
|
import axios from "axios";
|
|
634
641
|
import pluralize from "pluralize";
|
|
@@ -842,6 +849,45 @@ export interface Props<TItem = any> {
|
|
|
842
849
|
/** API endpoint for creating new items (e.g., "/api/products") — enables "New" button */
|
|
843
850
|
createUrl?: string;
|
|
844
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Render the built-in "New {item}" button in the card header (default
|
|
854
|
+
* `true` whenever `createUrl` is set). Set `false` to drive the create
|
|
855
|
+
* modal from your own trigger elsewhere — e.g. a button in the dashboard
|
|
856
|
+
* navbar's actions slot calling the exposed `openCreate()`. With no
|
|
857
|
+
* `title` and no `header` slot, the header is then dropped entirely
|
|
858
|
+
* rather than left empty.
|
|
859
|
+
*/
|
|
860
|
+
showCreateButton?: boolean;
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Render a `<tfoot>` mirroring the header row, so `#foot(<key>)` slots can
|
|
864
|
+
* hold per-column totals that line up under their data (#99). Without it
|
|
865
|
+
* there is no footer to put them in. For a fully custom footer, use the
|
|
866
|
+
* `custom-foot` slot instead.
|
|
867
|
+
*/
|
|
868
|
+
footClone?: boolean;
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Show a message row when there are no rows (default `true`). An empty
|
|
872
|
+
* table body is indistinguishable from a broken one, so the message is on
|
|
873
|
+
* by default. Override the wording with `emptyText`, or the whole row with
|
|
874
|
+
* the `empty` slot.
|
|
875
|
+
*/
|
|
876
|
+
showEmpty?: boolean;
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Message shown when there are no rows. Defaults to "No {items} found", or
|
|
880
|
+
* "No {items} match your filters" when a column filter is active.
|
|
881
|
+
*/
|
|
882
|
+
emptyText?: string;
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Rows currently expanded, as a `v-model`. Pair with the `row-expansion`
|
|
886
|
+
* slot to render detail content under a row (#112) instead of forcing every
|
|
887
|
+
* per-row detail into a modal.
|
|
888
|
+
*/
|
|
889
|
+
expandedItems?: TItem[];
|
|
890
|
+
|
|
845
891
|
/** Enable client-side filtering, sorting, and pagination on items array */
|
|
846
892
|
clientSide?: boolean;
|
|
847
893
|
}
|
|
@@ -872,6 +918,9 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
872
918
|
columnSize: "12",
|
|
873
919
|
card: true,
|
|
874
920
|
editModalSize: "lg",
|
|
921
|
+
showCreateButton: true,
|
|
922
|
+
footClone: false,
|
|
923
|
+
showEmpty: true,
|
|
875
924
|
});
|
|
876
925
|
|
|
877
926
|
const emit = defineEmits<{
|
|
@@ -899,6 +948,8 @@ const emit = defineEmits<{
|
|
|
899
948
|
deleteError: [item: T, error: any];
|
|
900
949
|
/** `v-model:sortBy` update, emitted with the normalized single-column sort array when sorting changes. */
|
|
901
950
|
'update:sortBy': [sortBy: BTableSortBy[]];
|
|
951
|
+
/** `v-model:expandedItems` update, emitted with the rows currently expanded. */
|
|
952
|
+
'update:expandedItems': [items: T[]];
|
|
902
953
|
/** `v-model:filters` update, emitted with the new filter map when a column filter changes. */
|
|
903
954
|
'update:filters': [filters: Record<string, string>];
|
|
904
955
|
/** `v-model:perPage` update, emitted with the new page size when the per-page selector changes. */
|
|
@@ -921,17 +972,51 @@ if (props.clientSide && (props.apiUrl || props.inertiaUrl)) {
|
|
|
921
972
|
// Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
|
|
922
973
|
const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.loading);
|
|
923
974
|
|
|
924
|
-
|
|
925
|
-
|
|
975
|
+
/*
|
|
976
|
+
* `perPage`, `sortBy` and `filters` are dual-purpose: with a `v-model` they are
|
|
977
|
+
* CONTROLLED state; without one they read as an INITIAL value.
|
|
978
|
+
*
|
|
979
|
+
* DXTable used to treat any passed value as controlled, so `:per-page="50"` —
|
|
980
|
+
* the natural way to say "start with 50 per page" — rendered a working-looking
|
|
981
|
+
* per-page selector that did nothing: the user picked 10, the select showed 10,
|
|
982
|
+
* and the table kept rendering 50 (#110). The control responded, nothing
|
|
983
|
+
* errored, and the table quietly ignored it.
|
|
984
|
+
*
|
|
985
|
+
* A prop is only controlled if the consumer is listening for its update, so
|
|
986
|
+
* that is what we check. It has to come off the raw vnode props: Vue strips
|
|
987
|
+
* DECLARED emit listeners out of `$attrs`, so `useAttrs()` can't see them.
|
|
988
|
+
* Listener presence doesn't meaningfully change over a component's life, so
|
|
989
|
+
* this is resolved once at setup rather than per render.
|
|
990
|
+
*/
|
|
991
|
+
const instance = getCurrentInstance();
|
|
992
|
+
|
|
993
|
+
const hasUpdateListener = (name: string): boolean => {
|
|
994
|
+
const vnodeProps = instance?.vnode.props ?? {};
|
|
995
|
+
// `v-model:per-page` compiles to `onUpdate:perPage`, but a hand-written
|
|
996
|
+
// `@update:per-page` stays hyphenated — accept either.
|
|
997
|
+
const hyphenated = name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
998
|
+
return `onUpdate:${name}` in vnodeProps || `onUpdate:${hyphenated}` in vnodeProps;
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const isControlled = {
|
|
1002
|
+
perPage: hasUpdateListener('perPage'),
|
|
1003
|
+
sortBy: hasUpdateListener('sortBy'),
|
|
1004
|
+
filters: hasUpdateListener('filters'),
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// Internal sortBy state, seeded from the prop when it's an initial value.
|
|
1008
|
+
const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
|
|
926
1009
|
|
|
927
|
-
|
|
928
|
-
|
|
1010
|
+
const effectiveSortBy = computed(() =>
|
|
1011
|
+
isControlled.sortBy && props.sortBy !== undefined ? props.sortBy : internalSortBy.value,
|
|
1012
|
+
);
|
|
929
1013
|
|
|
930
|
-
// Internal filters state
|
|
931
|
-
const internalFilters = ref<Record<string, string>>({});
|
|
1014
|
+
// Internal filters state, seeded from the prop when it's an initial value.
|
|
1015
|
+
const internalFilters = ref<Record<string, string>>({ ...(props.filters ?? {}) });
|
|
932
1016
|
|
|
933
|
-
|
|
934
|
-
|
|
1017
|
+
const effectiveFilters = computed(() =>
|
|
1018
|
+
isControlled.filters && props.filters !== undefined ? props.filters : internalFilters.value,
|
|
1019
|
+
);
|
|
935
1020
|
|
|
936
1021
|
// Computed: check if any field has filtering enabled
|
|
937
1022
|
const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
|
|
@@ -1111,15 +1196,13 @@ watch(() => props.filters, (newFilters, oldFilters) => {
|
|
|
1111
1196
|
refresh();
|
|
1112
1197
|
} else if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1113
1198
|
// Inertia mode - trigger navigation with new filters
|
|
1114
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1115
1199
|
router.get(
|
|
1116
1200
|
props.inertiaUrl!,
|
|
1117
1201
|
{
|
|
1118
1202
|
page: 1, // Reset to first page when filters change
|
|
1119
|
-
sortBy: currentSort.key,
|
|
1120
|
-
sortOrder: currentSort.order,
|
|
1121
1203
|
filters: newFilters,
|
|
1122
1204
|
perPage: effectivePerPage.value,
|
|
1205
|
+
...sortParams(effectiveSortBy.value),
|
|
1123
1206
|
},
|
|
1124
1207
|
{ preserveState: true }
|
|
1125
1208
|
);
|
|
@@ -1152,8 +1235,9 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
|
|
|
1152
1235
|
|
|
1153
1236
|
// Computed effective perPage (use external if provided, otherwise internal)
|
|
1154
1237
|
const effectivePerPage = computed(() => {
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1238
|
+
// Only when the consumer is actually driving it (v-model). A bare
|
|
1239
|
+
// `:per-page="50"` is an initial value — see `isControlled` (#110).
|
|
1240
|
+
if (isControlled.perPage && props.perPage !== undefined) {
|
|
1157
1241
|
return props.perPage;
|
|
1158
1242
|
}
|
|
1159
1243
|
|
|
@@ -1253,73 +1337,106 @@ const fieldsNeedingFilterValues = computed(() => {
|
|
|
1253
1337
|
// Error state for API mode
|
|
1254
1338
|
const apiError = ref<string | null>(null);
|
|
1255
1339
|
|
|
1256
|
-
|
|
1340
|
+
/**
|
|
1341
|
+
* The active sort, or `null` when there is none.
|
|
1342
|
+
*
|
|
1343
|
+
* The header sort cycles asc → desc → *unsorted*, and in that third state
|
|
1344
|
+
* BTable reports the column with no `order`. There is deliberately no fallback
|
|
1345
|
+
* column here: sorting by a magic `created_at` when the user has asked for no
|
|
1346
|
+
* sort means requesting a column the consumer never declared, which a strict
|
|
1347
|
+
* endpoint (e.g. spatie's QueryBuilder, where an undeclared sort is a 400)
|
|
1348
|
+
* rejects outright. No sort selected means no sort sent — the server applies
|
|
1349
|
+
* its own default ordering. Pass `sortBy` to give the table an initial sort.
|
|
1350
|
+
*/
|
|
1351
|
+
const activeSort = (sortBy: readonly BTableSortBy[] | undefined) => {
|
|
1352
|
+
const sort = sortBy?.[0];
|
|
1353
|
+
if (!sort?.key || !sort.order) return null;
|
|
1354
|
+
return { key: sort.key, order: sort.order };
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
// Sort params for a request — an empty object when nothing is sorted.
|
|
1358
|
+
const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
|
|
1359
|
+
const sort = activeSort(sortBy);
|
|
1360
|
+
return sort ? { sortBy: sort.key, sortOrder: sort.order } : {};
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
// Internal provider function when apiUrl is provided. It deliberately does NOT
|
|
1364
|
+
// catch: `effectiveProvider` wraps every provider (including a consumer's own)
|
|
1365
|
+
// in one error handler, so no provider can fail silently.
|
|
1257
1366
|
const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1258
1367
|
if (!props.apiUrl) return [];
|
|
1259
1368
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
: { key: 'created_at', order: 'desc' };
|
|
1267
|
-
|
|
1268
|
-
// Build request parameters
|
|
1269
|
-
const params: any = {
|
|
1270
|
-
page: context.currentPage,
|
|
1271
|
-
perPage: effectivePerPage.value,
|
|
1272
|
-
sortBy: sort.key,
|
|
1273
|
-
sortOrder: sort.order || 'desc',
|
|
1274
|
-
filters: effectiveFilters.value,
|
|
1275
|
-
};
|
|
1276
|
-
|
|
1277
|
-
// Request filter values on initial load
|
|
1278
|
-
if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
|
|
1279
|
-
params.filterValues = fieldsNeedingFilterValues.value;
|
|
1280
|
-
}
|
|
1369
|
+
const params: any = {
|
|
1370
|
+
page: context.currentPage,
|
|
1371
|
+
perPage: effectivePerPage.value,
|
|
1372
|
+
filters: effectiveFilters.value,
|
|
1373
|
+
...sortParams(context.sortBy),
|
|
1374
|
+
};
|
|
1281
1375
|
|
|
1282
|
-
|
|
1376
|
+
// Request filter values on initial load
|
|
1377
|
+
if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
|
|
1378
|
+
params.filterValues = fieldsNeedingFilterValues.value;
|
|
1379
|
+
}
|
|
1283
1380
|
|
|
1284
|
-
|
|
1285
|
-
if (response.data.pagination) {
|
|
1286
|
-
apiPaginationMeta.value = response.data.pagination;
|
|
1287
|
-
}
|
|
1381
|
+
const response = await axios.get(props.apiUrl, { params });
|
|
1288
1382
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1383
|
+
// Extract and store pagination metadata for display
|
|
1384
|
+
if (response.data.pagination) {
|
|
1385
|
+
apiPaginationMeta.value = response.data.pagination;
|
|
1386
|
+
}
|
|
1293
1387
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1388
|
+
// Extract and store filter values
|
|
1389
|
+
if (response.data.filterValues) {
|
|
1390
|
+
apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
|
|
1391
|
+
}
|
|
1297
1392
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|| error?.message
|
|
1301
|
-
|| 'Failed to load data. Please try again.';
|
|
1302
|
-
apiError.value = errorMessage;
|
|
1393
|
+
return response.data.data;
|
|
1394
|
+
};
|
|
1303
1395
|
|
|
1396
|
+
/**
|
|
1397
|
+
* Wraps whichever provider is in play — the built-in `apiUrl` one or a
|
|
1398
|
+
* consumer's own `provider` — so a rejected fetch surfaces an error instead of
|
|
1399
|
+
* rendering as zero rows. A failed query and a genuinely empty result look
|
|
1400
|
+
* identical otherwise, which is how a broken table passes review and then shows
|
|
1401
|
+
* a blank page to a user. A consumer's provider used to be the silent case: the
|
|
1402
|
+
* error handling lived inside the internal one, so it never covered them.
|
|
1403
|
+
*/
|
|
1404
|
+
const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1405
|
+
const provider = props.provider || (props.apiUrl ? internalProvider : undefined);
|
|
1406
|
+
if (!provider) return [];
|
|
1407
|
+
|
|
1408
|
+
apiError.value = null;
|
|
1409
|
+
try {
|
|
1410
|
+
return await provider(context);
|
|
1411
|
+
} catch (error: any) {
|
|
1412
|
+
// eslint-disable-next-line no-console
|
|
1413
|
+
console.error('DXTable: the data provider failed', error);
|
|
1414
|
+
apiError.value =
|
|
1415
|
+
error?.response?.data?.message ||
|
|
1416
|
+
error?.message ||
|
|
1417
|
+
'Failed to load data. Please try again.';
|
|
1304
1418
|
return [];
|
|
1305
1419
|
}
|
|
1306
1420
|
};
|
|
1307
1421
|
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1422
|
+
// `wrappedProvider` is resolved fresh on each call, so this computed's IDENTITY
|
|
1423
|
+
// must not change when `apiUrl` does — BTable refetches when the provider
|
|
1424
|
+
// function changes, which would double every request that the explicit apiUrl
|
|
1425
|
+
// watcher already refetches (#82).
|
|
1426
|
+
const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
|
|
1427
|
+
props.provider || props.apiUrl ? wrappedProvider : undefined,
|
|
1428
|
+
);
|
|
1310
1429
|
|
|
1311
1430
|
const handlePageChange = (page: number) => {
|
|
1312
1431
|
// If inertiaUrl provided, handle navigation automatically
|
|
1313
1432
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1314
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1315
1433
|
router.get(
|
|
1316
1434
|
props.inertiaUrl!,
|
|
1317
1435
|
{
|
|
1318
1436
|
page,
|
|
1319
|
-
sortBy: currentSort.key,
|
|
1320
|
-
sortOrder: currentSort.order,
|
|
1321
1437
|
filters: effectiveFilters.value,
|
|
1322
1438
|
perPage: effectivePerPage.value,
|
|
1439
|
+
...sortParams(effectiveSortBy.value),
|
|
1323
1440
|
},
|
|
1324
1441
|
{ preserveState: true }
|
|
1325
1442
|
);
|
|
@@ -1353,8 +1470,8 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
|
1353
1470
|
}
|
|
1354
1471
|
}
|
|
1355
1472
|
|
|
1356
|
-
//
|
|
1357
|
-
if (
|
|
1473
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1474
|
+
if (!isControlled.sortBy) {
|
|
1358
1475
|
internalSortBy.value = normalizedSortBy;
|
|
1359
1476
|
}
|
|
1360
1477
|
|
|
@@ -1363,28 +1480,26 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
|
1363
1480
|
|
|
1364
1481
|
// Handle Inertia navigation automatically if URL provided
|
|
1365
1482
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
router.get(props.inertiaUrl!, params, { preserveState: true });
|
|
1483
|
+
// The cleared state has a key but no order — `sortParams` sends nothing
|
|
1484
|
+
// for it, rather than inventing an ascending sort the user didn't ask for.
|
|
1485
|
+
router.get(
|
|
1486
|
+
props.inertiaUrl!,
|
|
1487
|
+
{
|
|
1488
|
+
page: props.pagination?.current_page || 1,
|
|
1489
|
+
filters: effectiveFilters.value,
|
|
1490
|
+
perPage: effectivePerPage.value,
|
|
1491
|
+
...sortParams(normalizedSortBy),
|
|
1492
|
+
},
|
|
1493
|
+
{ preserveState: true },
|
|
1494
|
+
);
|
|
1380
1495
|
}
|
|
1381
1496
|
|
|
1382
|
-
// Emit simplified sortChange event for backward compatibility
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1497
|
+
// Emit simplified sortChange event for backward compatibility. Its payload
|
|
1498
|
+
// can only express an ACTIVE sort, so the cleared state emits nothing here —
|
|
1499
|
+
// listen to `update:sortBy` (which carries the empty array) to observe it.
|
|
1500
|
+
const sort = activeSort(normalizedSortBy);
|
|
1501
|
+
if (isInertiaMode.value && sort) {
|
|
1502
|
+
emit('sortChange', sort);
|
|
1388
1503
|
}
|
|
1389
1504
|
};
|
|
1390
1505
|
|
|
@@ -1404,8 +1519,8 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1404
1519
|
}
|
|
1405
1520
|
const perPageNum = typeof rawPerPage === 'string' ? parseInt(rawPerPage, 10) : rawPerPage;
|
|
1406
1521
|
|
|
1407
|
-
//
|
|
1408
|
-
if (
|
|
1522
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1523
|
+
if (!isControlled.perPage) {
|
|
1409
1524
|
internalPerPage.value = perPageNum;
|
|
1410
1525
|
}
|
|
1411
1526
|
|
|
@@ -1423,15 +1538,13 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1423
1538
|
|
|
1424
1539
|
// Handle navigation automatically
|
|
1425
1540
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1426
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1427
1541
|
router.get(
|
|
1428
1542
|
props.inertiaUrl!,
|
|
1429
1543
|
{
|
|
1430
1544
|
page: 1, // Reset to first page when changing perPage
|
|
1431
|
-
sortBy: currentSort.key,
|
|
1432
|
-
sortOrder: currentSort.order,
|
|
1433
1545
|
filters: effectiveFilters.value,
|
|
1434
1546
|
perPage: perPageNum,
|
|
1547
|
+
...sortParams(effectiveSortBy.value),
|
|
1435
1548
|
},
|
|
1436
1549
|
{ preserveState: true }
|
|
1437
1550
|
);
|
|
@@ -1455,8 +1568,8 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1455
1568
|
delete newFilters[fieldKey];
|
|
1456
1569
|
}
|
|
1457
1570
|
|
|
1458
|
-
//
|
|
1459
|
-
if (
|
|
1571
|
+
// Write internal state unless the consumer controls it with v-model.
|
|
1572
|
+
if (!isControlled.filters) {
|
|
1460
1573
|
internalFilters.value = newFilters;
|
|
1461
1574
|
}
|
|
1462
1575
|
|
|
@@ -1478,14 +1591,12 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1478
1591
|
filterDebounceTimer = setTimeout(() => {
|
|
1479
1592
|
// Handle Inertia navigation automatically if URL provided
|
|
1480
1593
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1481
|
-
const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
|
|
1482
1594
|
router.get(
|
|
1483
1595
|
props.inertiaUrl!,
|
|
1484
1596
|
{
|
|
1485
1597
|
page: 1, // Reset to first page when filtering
|
|
1486
|
-
sortBy: currentSort.key,
|
|
1487
|
-
sortOrder: currentSort.order,
|
|
1488
1598
|
filters: newFilters,
|
|
1599
|
+
...sortParams(effectiveSortBy.value),
|
|
1489
1600
|
},
|
|
1490
1601
|
{ preserveState: true }
|
|
1491
1602
|
);
|
|
@@ -1519,6 +1630,12 @@ const refresh = () => {
|
|
|
1519
1630
|
|
|
1520
1631
|
// Edit Modal State
|
|
1521
1632
|
const showEditModal = ref(false);
|
|
1633
|
+
|
|
1634
|
+
// Bumped on every modal open (edit or create) to key the DXForm subtree, so
|
|
1635
|
+
// each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
|
|
1636
|
+
// which reseeds the SAME record — remounting there would discard edits the
|
|
1637
|
+
// user has already made while the fetch was in flight.
|
|
1638
|
+
const editFormInstanceKey = ref(0);
|
|
1522
1639
|
const selectedItem = ref<T | null>(null);
|
|
1523
1640
|
const editForm = ref<any>(null);
|
|
1524
1641
|
const activeTabIndex = ref(0);
|
|
@@ -1554,6 +1671,53 @@ try {
|
|
|
1554
1671
|
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1555
1672
|
// a real custom-value override.
|
|
1556
1673
|
const tableSlots = useSlots();
|
|
1674
|
+
|
|
1675
|
+
/*
|
|
1676
|
+
* Slot forwarding to the inner table.
|
|
1677
|
+
*
|
|
1678
|
+
* DXTable used to forward only slots whose name started with `cell`, which is
|
|
1679
|
+
* why it had no footer/totals row (#99), no empty state (#111) and no
|
|
1680
|
+
* expandable rows (#112): BTable supports all three, but the slots never
|
|
1681
|
+
* reached it. Anything BTable understands is now forwarded, with two
|
|
1682
|
+
* deliberate exceptions that DXTable renders itself:
|
|
1683
|
+
*
|
|
1684
|
+
* - `head(<key>)` — DXTable draws its own column headers (sort indicators,
|
|
1685
|
+
* field hints). Forwarding a consumer's would silently drop those.
|
|
1686
|
+
* - `thead-top` — DXTable renders the inline filter row there.
|
|
1687
|
+
*/
|
|
1688
|
+
const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
|
|
1689
|
+
const TABLE_SLOT_NAMES = new Set([
|
|
1690
|
+
'custom-foot',
|
|
1691
|
+
'empty',
|
|
1692
|
+
'empty-filtered',
|
|
1693
|
+
'row-expansion',
|
|
1694
|
+
'table-busy',
|
|
1695
|
+
'table-caption',
|
|
1696
|
+
'table-colgroup',
|
|
1697
|
+
'thead-sub',
|
|
1698
|
+
'top-row',
|
|
1699
|
+
'bottom-row',
|
|
1700
|
+
]);
|
|
1701
|
+
|
|
1702
|
+
const isTableSlot = (name: string) =>
|
|
1703
|
+
TABLE_SLOT_NAMES.has(name) || TABLE_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
1704
|
+
|
|
1705
|
+
const forwardedTableSlotNames = computed(() =>
|
|
1706
|
+
Object.keys(tableSlots).filter((name) => isTableSlot(name)),
|
|
1707
|
+
);
|
|
1708
|
+
|
|
1709
|
+
// Table-level features of the inner BTable, exposed verbatim and bound to all
|
|
1710
|
+
// three data modes from one place.
|
|
1711
|
+
const tablePassthroughProps = computed(() => ({
|
|
1712
|
+
footClone: props.footClone,
|
|
1713
|
+
showEmpty: props.showEmpty,
|
|
1714
|
+
emptyText:
|
|
1715
|
+
props.emptyText ??
|
|
1716
|
+
(hasActiveFilters.value
|
|
1717
|
+
? `No ${pluralItemName.value} match your filters`
|
|
1718
|
+
: `No ${pluralItemName.value} found`),
|
|
1719
|
+
expandedItems: props.expandedItems,
|
|
1720
|
+
}));
|
|
1557
1721
|
const editFieldKeys = computed<string[]>(() =>
|
|
1558
1722
|
(props.editFields ?? []).map((field: any) => field.key),
|
|
1559
1723
|
);
|
|
@@ -1580,6 +1744,11 @@ const tabAfterSlotKeys = computed(() =>
|
|
|
1580
1744
|
const singularItemName = computed(() => props.itemName);
|
|
1581
1745
|
const pluralItemName = computed(() => pluralize(props.itemName));
|
|
1582
1746
|
|
|
1747
|
+
// The built-in "New {item}" button needs both a create endpoint and consent.
|
|
1748
|
+
// Gating the header's own v-if on this (not on `createUrl`) means a table that
|
|
1749
|
+
// only had a header to host the button loses the empty header too.
|
|
1750
|
+
const showsCreateButton = computed(() => Boolean(props.createUrl) && props.showCreateButton);
|
|
1751
|
+
|
|
1583
1752
|
// Computed: Modal title (supports function)
|
|
1584
1753
|
const computedModalTitle = computed(() => {
|
|
1585
1754
|
if (isCreateMode.value) {
|
|
@@ -1633,6 +1802,7 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1633
1802
|
}
|
|
1634
1803
|
|
|
1635
1804
|
// Open modal
|
|
1805
|
+
editFormInstanceKey.value++;
|
|
1636
1806
|
showEditModal.value = true;
|
|
1637
1807
|
|
|
1638
1808
|
// Optionally replace the row-seeded data with the full record.
|
|
@@ -1707,6 +1877,7 @@ const handleCreateNew = () => {
|
|
|
1707
1877
|
});
|
|
1708
1878
|
editForm.value.clearErrors();
|
|
1709
1879
|
}
|
|
1880
|
+
editFormInstanceKey.value++;
|
|
1710
1881
|
showEditModal.value = true;
|
|
1711
1882
|
};
|
|
1712
1883
|
|