@omnitend/dashboard-for-laravel 0.23.0 → 0.25.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 +3 -0
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +6 -0
- package/dist/components/extended/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXTable.vue.d.ts +25 -0
- package/dist/dashboard-for-laravel.js +8652 -8548
- 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/style.css +1 -1
- package/dist/types/index.d.ts +21 -0
- package/docs/public/api-reference.json +17 -3
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +113 -8
- package/resources/js/components/base/DAutocomplete.vue +54 -1
- package/resources/js/components/extended/DXDashboard.vue +5 -0
- package/resources/js/components/extended/DXDashboardNavbar.vue +96 -54
- package/resources/js/components/extended/DXField.vue +31 -0
- package/resources/js/components/extended/DXTable.vue +258 -71
- package/resources/js/components/extended/DXUserAvatar.vue +5 -1
- package/resources/js/types/index.ts +23 -0
|
@@ -69,40 +69,40 @@
|
|
|
69
69
|
<!-- Text Filter -->
|
|
70
70
|
<DFormInput
|
|
71
71
|
v-if="field.filter === 'text'"
|
|
72
|
-
:model-value="effectiveFilters[field
|
|
72
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
73
73
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
74
74
|
size="sm"
|
|
75
|
-
@update:model-value="handleFilterChange(field
|
|
75
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
76
76
|
/>
|
|
77
77
|
|
|
78
78
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
79
79
|
<DAutocomplete
|
|
80
80
|
v-else-if="field.filter === 'select'"
|
|
81
|
-
:model-value="effectiveFilters[field
|
|
81
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
82
82
|
:options="getFieldFilterOptions(field)"
|
|
83
|
-
:placeholder="field.filterPlaceholder ||
|
|
83
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
84
84
|
size="sm"
|
|
85
85
|
open-on-focus
|
|
86
|
-
@update:model-value="
|
|
86
|
+
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
87
87
|
/>
|
|
88
88
|
|
|
89
89
|
<!-- Number Filter -->
|
|
90
90
|
<DFormInput
|
|
91
91
|
v-else-if="field.filter === 'number'"
|
|
92
|
-
:model-value="effectiveFilters[field
|
|
92
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
93
93
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
94
94
|
type="number"
|
|
95
95
|
size="sm"
|
|
96
|
-
@update:model-value="handleFilterChange(field
|
|
96
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
97
97
|
/>
|
|
98
98
|
|
|
99
99
|
<!-- Date Filter -->
|
|
100
100
|
<DFormInput
|
|
101
101
|
v-else-if="field.filter === 'date'"
|
|
102
|
-
:model-value="effectiveFilters[field
|
|
102
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
103
103
|
type="date"
|
|
104
104
|
size="sm"
|
|
105
|
-
@update:model-value="handleFilterChange(field
|
|
105
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
106
106
|
/>
|
|
107
107
|
|
|
108
108
|
<!-- No filter for this column -->
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
116
116
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
117
117
|
<div class="flex-grow-1">
|
|
118
|
-
<div class="fw-semibold">{{ label
|
|
118
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
119
119
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
120
120
|
</div>
|
|
121
121
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -165,40 +165,40 @@
|
|
|
165
165
|
<!-- Text Filter -->
|
|
166
166
|
<DFormInput
|
|
167
167
|
v-if="field.filter === 'text'"
|
|
168
|
-
:model-value="effectiveFilters[field
|
|
168
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
169
169
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
170
170
|
size="sm"
|
|
171
|
-
@update:model-value="handleFilterChange(field
|
|
171
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
172
172
|
/>
|
|
173
173
|
|
|
174
174
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
175
175
|
<DAutocomplete
|
|
176
176
|
v-else-if="field.filter === 'select'"
|
|
177
|
-
:model-value="effectiveFilters[field
|
|
177
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
178
178
|
:options="getFieldFilterOptions(field)"
|
|
179
|
-
:placeholder="field.filterPlaceholder ||
|
|
179
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
180
180
|
size="sm"
|
|
181
181
|
open-on-focus
|
|
182
|
-
@update:model-value="
|
|
182
|
+
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
183
183
|
/>
|
|
184
184
|
|
|
185
185
|
<!-- Number Filter -->
|
|
186
186
|
<DFormInput
|
|
187
187
|
v-else-if="field.filter === 'number'"
|
|
188
|
-
:model-value="effectiveFilters[field
|
|
188
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
189
189
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
190
190
|
type="number"
|
|
191
191
|
size="sm"
|
|
192
|
-
@update:model-value="handleFilterChange(field
|
|
192
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
193
193
|
/>
|
|
194
194
|
|
|
195
195
|
<!-- Date Filter -->
|
|
196
196
|
<DFormInput
|
|
197
197
|
v-else-if="field.filter === 'date'"
|
|
198
|
-
:model-value="effectiveFilters[field
|
|
198
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
199
199
|
type="date"
|
|
200
200
|
size="sm"
|
|
201
|
-
@update:model-value="handleFilterChange(field
|
|
201
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
202
202
|
/>
|
|
203
203
|
|
|
204
204
|
<!-- No filter for this column -->
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
212
212
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
213
213
|
<div class="flex-grow-1">
|
|
214
|
-
<div class="fw-semibold">{{ label
|
|
214
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
215
215
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
216
216
|
</div>
|
|
217
217
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -262,40 +262,40 @@
|
|
|
262
262
|
<!-- Text Filter -->
|
|
263
263
|
<DFormInput
|
|
264
264
|
v-if="field.filter === 'text'"
|
|
265
|
-
:model-value="effectiveFilters[field
|
|
265
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
266
266
|
:placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
|
|
267
267
|
size="sm"
|
|
268
|
-
@update:model-value="handleFilterChange(field
|
|
268
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
269
269
|
/>
|
|
270
270
|
|
|
271
271
|
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
272
272
|
<DAutocomplete
|
|
273
273
|
v-else-if="field.filter === 'select'"
|
|
274
|
-
:model-value="effectiveFilters[field
|
|
274
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
275
275
|
:options="getFieldFilterOptions(field)"
|
|
276
|
-
:placeholder="field.filterPlaceholder ||
|
|
276
|
+
:placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
|
|
277
277
|
size="sm"
|
|
278
278
|
open-on-focus
|
|
279
|
-
@update:model-value="
|
|
279
|
+
@update:model-value="handleSelectFilterChange(field, $event)"
|
|
280
280
|
/>
|
|
281
281
|
|
|
282
282
|
<!-- Number Filter -->
|
|
283
283
|
<DFormInput
|
|
284
284
|
v-else-if="field.filter === 'number'"
|
|
285
|
-
:model-value="effectiveFilters[field
|
|
285
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
286
286
|
:placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
|
|
287
287
|
type="number"
|
|
288
288
|
size="sm"
|
|
289
|
-
@update:model-value="handleFilterChange(field
|
|
289
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
290
290
|
/>
|
|
291
291
|
|
|
292
292
|
<!-- Date Filter -->
|
|
293
293
|
<DFormInput
|
|
294
294
|
v-else-if="field.filter === 'date'"
|
|
295
|
-
:model-value="effectiveFilters[field
|
|
295
|
+
:model-value="effectiveFilters[filterKeyFor(field)] || ''"
|
|
296
296
|
type="date"
|
|
297
297
|
size="sm"
|
|
298
|
-
@update:model-value="handleFilterChange(field
|
|
298
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
|
|
299
299
|
/>
|
|
300
300
|
|
|
301
301
|
<!-- No filter for this column -->
|
|
@@ -308,7 +308,7 @@
|
|
|
308
308
|
<template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
|
|
309
309
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
|
310
310
|
<div class="flex-grow-1">
|
|
311
|
-
<div class="fw-semibold">{{ label
|
|
311
|
+
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
312
312
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
313
313
|
</div>
|
|
314
314
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
@@ -431,15 +431,15 @@
|
|
|
431
431
|
</div>
|
|
432
432
|
|
|
433
433
|
<!-- Pagination and Controls (API mode) -->
|
|
434
|
-
<div v-if="isProviderMode &&
|
|
434
|
+
<div v-if="isProviderMode && providerPagination" class="mt-3">
|
|
435
435
|
<!-- Top row: Pagination and Per-page selector -->
|
|
436
436
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
437
437
|
<!-- Pagination controls (only when multiple pages) -->
|
|
438
438
|
<DPagination
|
|
439
|
-
v-if="showPagination &&
|
|
440
|
-
:model-value="
|
|
441
|
-
:total-rows="
|
|
442
|
-
:per-page="
|
|
439
|
+
v-if="showPagination && providerPagination.total > providerPagination.per_page"
|
|
440
|
+
:model-value="providerPagination.current_page"
|
|
441
|
+
:total-rows="providerPagination.total"
|
|
442
|
+
:per-page="providerPagination.per_page"
|
|
443
443
|
size="sm"
|
|
444
444
|
@update:model-value="handleApiPageChange"
|
|
445
445
|
/>
|
|
@@ -462,18 +462,18 @@
|
|
|
462
462
|
<!-- Bottom row: Info text -->
|
|
463
463
|
<div class="small text-muted">
|
|
464
464
|
<div>
|
|
465
|
-
<template v-if="
|
|
466
|
-
{{
|
|
465
|
+
<template v-if="providerPagination.total > providerPagination.per_page">
|
|
466
|
+
{{ providerPagination.from }} to {{ providerPagination.to }} out of {{ providerPagination.total }} {{ providerPagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
467
467
|
</template>
|
|
468
|
-
<template v-else-if="
|
|
469
|
-
{{
|
|
468
|
+
<template v-else-if="providerPagination.total === 1">
|
|
469
|
+
{{ providerPagination.total }} {{ singularItemName }}.
|
|
470
470
|
</template>
|
|
471
471
|
<template v-else>
|
|
472
|
-
{{
|
|
472
|
+
{{ providerPagination.total }} {{ pluralItemName }}.
|
|
473
473
|
</template>
|
|
474
474
|
</div>
|
|
475
|
-
<div v-if="hasActiveFilters &&
|
|
476
|
-
<small>Filtered from {{
|
|
475
|
+
<div v-if="hasActiveFilters && providerPagination.total_unfiltered">
|
|
476
|
+
<small>Filtered from {{ providerPagination.total_unfiltered }} {{ providerPagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
|
|
477
477
|
</div>
|
|
478
478
|
</div>
|
|
479
479
|
</div>
|
|
@@ -664,6 +664,35 @@ export interface FilterOption {
|
|
|
664
664
|
export interface TableField {
|
|
665
665
|
key: string;
|
|
666
666
|
label?: string;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The key this column's filter is sent under, when it differs from the
|
|
670
|
+
* column's own key (default: `key`). Lets a *display* column filter on a
|
|
671
|
+
* different server param — e.g. a "Customer" column rendering a name but
|
|
672
|
+
* filtering on `customer_id` — instead of forcing the column to be named
|
|
673
|
+
* after the server's filter param and the human-facing value into a
|
|
674
|
+
* `#cell` slot.
|
|
675
|
+
*/
|
|
676
|
+
filterKey?: string;
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* For a `select` filter: adds an option meaning "has no value", e.g.
|
|
680
|
+
* `filterNullText: "Unassigned"` on an assignee column. Selecting it sends
|
|
681
|
+
* `filterNullValue` (default `"null"`) as the filter value; client-side, it
|
|
682
|
+
* matches rows whose value is null, undefined or empty.
|
|
683
|
+
*/
|
|
684
|
+
filterNullText?: string;
|
|
685
|
+
|
|
686
|
+
/** The value sent when the `filterNullText` option is chosen. Default `"null"`. */
|
|
687
|
+
filterNullValue?: string;
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Text of the "clear this filter" option at the top of a `select` filter's
|
|
691
|
+
* list (default: the filter's placeholder, e.g. "All statuses"). The list
|
|
692
|
+
* otherwise offers no way back to *unfiltered* once a value is picked —
|
|
693
|
+
* only the ✕, which isn't discoverable from inside the open dropdown.
|
|
694
|
+
*/
|
|
695
|
+
filterAllText?: string;
|
|
667
696
|
sortable?: boolean;
|
|
668
697
|
hint?: string;
|
|
669
698
|
filter?: FilterType;
|
|
@@ -907,7 +936,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
907
936
|
}),
|
|
908
937
|
showPagination: true,
|
|
909
938
|
showPerPageSelector: true,
|
|
910
|
-
perPageOptions: () => [10,
|
|
939
|
+
perPageOptions: () => [10, 20, 50, 100],
|
|
911
940
|
currentPage: 1,
|
|
912
941
|
// perPage: 10, // Don't set default - let internalPerPage handle it
|
|
913
942
|
striped: false,
|
|
@@ -990,20 +1019,37 @@ const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.l
|
|
|
990
1019
|
*/
|
|
991
1020
|
const instance = getCurrentInstance();
|
|
992
1021
|
|
|
993
|
-
const
|
|
1022
|
+
const hasListener = (event: string): boolean => {
|
|
994
1023
|
const vnodeProps = instance?.vnode.props ?? {};
|
|
995
|
-
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
|
|
1024
|
+
const toHandlerKey = (name: string) => `on${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
1025
|
+
// `v-model:per-page` / `@row-clicked` compile to camelCase handler keys, but
|
|
1026
|
+
// a hand-written hyphenated listener stays hyphenated — accept either.
|
|
1027
|
+
const hyphenated = event.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
1028
|
+
return toHandlerKey(event) in vnodeProps || toHandlerKey(hyphenated) in vnodeProps;
|
|
999
1029
|
};
|
|
1000
1030
|
|
|
1001
1031
|
const isControlled = {
|
|
1002
|
-
perPage:
|
|
1003
|
-
sortBy:
|
|
1004
|
-
filters:
|
|
1032
|
+
perPage: hasListener('update:perPage'),
|
|
1033
|
+
sortBy: hasListener('update:sortBy'),
|
|
1034
|
+
filters: hasListener('update:filters'),
|
|
1005
1035
|
};
|
|
1006
1036
|
|
|
1037
|
+
/*
|
|
1038
|
+
* Rows are interactive when clicking one does something: the built-in edit
|
|
1039
|
+
* modal (`editFields`) opens, or the consumer is listening for `row-clicked`.
|
|
1040
|
+
* The cursor and hover affordance follow from that — DXTable already knows,
|
|
1041
|
+
* so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
|
|
1042
|
+
* The listener case was the gap: only `editFields` used to count.
|
|
1043
|
+
*/
|
|
1044
|
+
const rowsAreInteractive = computed(
|
|
1045
|
+
() => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
const rowCursor = computed(() => (rowsAreInteractive.value ? 'pointer' : 'default'));
|
|
1049
|
+
const rowHoverBackground = computed(() =>
|
|
1050
|
+
rowsAreInteractive.value ? 'var(--bs-table-hover-bg)' : 'inherit',
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1007
1053
|
// Internal sortBy state, seeded from the prop when it's an initial value.
|
|
1008
1054
|
const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
|
|
1009
1055
|
|
|
@@ -1030,6 +1076,52 @@ const hasActiveFilters = computed(() => {
|
|
|
1030
1076
|
// API mode pagination metadata (extracted from responses)
|
|
1031
1077
|
const apiPaginationMeta = ref<PaginationData | null>(null);
|
|
1032
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Pagination shown in provider mode.
|
|
1081
|
+
*
|
|
1082
|
+
* Only the built-in `apiUrl` provider can populate `apiPaginationMeta` — it's
|
|
1083
|
+
* the one that knows to read `response.data.pagination`. A CUSTOM provider
|
|
1084
|
+
* returns rows and nothing else, so the pager simply never rendered: a table
|
|
1085
|
+
* with no pagination and no warning, which is why a page needing custom request
|
|
1086
|
+
* shaping quietly lost its pager (#106). A custom provider now takes its
|
|
1087
|
+
* pagination from the `pagination` prop, and says so when it's missing.
|
|
1088
|
+
*/
|
|
1089
|
+
// `pagination` carries a default from withDefaults, so props alone can't tell
|
|
1090
|
+
// whether the consumer actually passed one — ask the vnode. (Gating on
|
|
1091
|
+
// `total > 0` instead would treat a legitimately empty page as "no pagination".)
|
|
1092
|
+
const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
|
|
1093
|
+
|
|
1094
|
+
const providerPagination = computed<PaginationData | null>(() => {
|
|
1095
|
+
if (apiPaginationMeta.value) return apiPaginationMeta.value;
|
|
1096
|
+
if (props.provider && paginationWasProvided && props.pagination) {
|
|
1097
|
+
return props.pagination;
|
|
1098
|
+
}
|
|
1099
|
+
return null;
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
/*
|
|
1103
|
+
* Loud, once, rather than a silently pager-less table — but only AFTER the
|
|
1104
|
+
* provider has actually run, and judged on `providerPagination` (not the raw
|
|
1105
|
+
* apiPaginationMeta). Warning at setup would fire on every consumer whose
|
|
1106
|
+
* `pagination` prop arrives with the response rather than before it.
|
|
1107
|
+
*/
|
|
1108
|
+
const providerHasRun = ref(false);
|
|
1109
|
+
let warnedAboutProviderPagination = false;
|
|
1110
|
+
watch(
|
|
1111
|
+
() => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
|
|
1112
|
+
([provider, showPagination, pagination, hasRun]) => {
|
|
1113
|
+
if (warnedAboutProviderPagination || !hasRun) return;
|
|
1114
|
+
if (provider && showPagination && !pagination) {
|
|
1115
|
+
warnedAboutProviderPagination = true;
|
|
1116
|
+
// eslint-disable-next-line no-console
|
|
1117
|
+
console.warn(
|
|
1118
|
+
'[DXTable] A custom `provider` cannot report its own pagination, so no pager will render. ' +
|
|
1119
|
+
'Pass the page metadata via the `pagination` prop, or set `:show-pagination="false"` to hide the pager deliberately.',
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1033
1125
|
// ============================================
|
|
1034
1126
|
// Client-Side Mode: Filtering, Sorting, Pagination
|
|
1035
1127
|
// ============================================
|
|
@@ -1051,8 +1143,23 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1051
1143
|
return props.items.filter(item => {
|
|
1052
1144
|
return filterKeys.every(key => {
|
|
1053
1145
|
const filterValue = filters[key].trim().toLowerCase();
|
|
1054
|
-
|
|
1055
|
-
const
|
|
1146
|
+
// Resolve by the FILTER key, which may differ from the column's key.
|
|
1147
|
+
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
1148
|
+
// `filterKey` names the param the SERVER filters on; a local row may
|
|
1149
|
+
// not carry it at all (a `customer_name` row filtered on
|
|
1150
|
+
// `customer_id`). Match on the filter key when the row has it,
|
|
1151
|
+
// otherwise fall back to the column's own value — which is also the
|
|
1152
|
+
// one the user can actually see and type against.
|
|
1153
|
+
const itemValue =
|
|
1154
|
+
field && key in (item as any)
|
|
1155
|
+
? (item as any)[key]
|
|
1156
|
+
: (item as any)[field?.key ?? key];
|
|
1157
|
+
|
|
1158
|
+
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
1159
|
+
// MATCHES an absent value rather than failing on it.
|
|
1160
|
+
if (field?.filterNullText && filters[key] === filterNullValueFor(field)) {
|
|
1161
|
+
return itemValue === null || itemValue === undefined || itemValue === '';
|
|
1162
|
+
}
|
|
1056
1163
|
|
|
1057
1164
|
if (itemValue === null || itemValue === undefined) {
|
|
1058
1165
|
return false;
|
|
@@ -1122,20 +1229,69 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1122
1229
|
const apiFilterValues = ref<Record<string, string[]>>({});
|
|
1123
1230
|
|
|
1124
1231
|
// Computed: Get effective filter options for a field
|
|
1232
|
+
// The key a column's filter is SENT under, which is not always the column's own
|
|
1233
|
+
// key: a display column can filter on a different server param via `filterKey`.
|
|
1234
|
+
const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key;
|
|
1235
|
+
|
|
1236
|
+
// The value that means "has no value" for a select filter (#106).
|
|
1237
|
+
const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
|
|
1238
|
+
|
|
1239
|
+
// The filter's placeholder, and the label of its "no filter" option — the same
|
|
1240
|
+
// words, so picking the top option visibly returns the field to its resting state.
|
|
1241
|
+
// See getFieldFilterOptions: an empty-string option value makes bvn render no
|
|
1242
|
+
// options at all, so "no filter" needs a value that isn't empty.
|
|
1243
|
+
const FILTER_ALL_VALUE = '__dx_filter_all__';
|
|
1244
|
+
|
|
1245
|
+
const handleSelectFilterChange = (field: TableField, value: unknown) => {
|
|
1246
|
+
const selected = (value ?? '') as string;
|
|
1247
|
+
handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
const filterAllLabelFor = (field: TableField): string =>
|
|
1251
|
+
field.filterAllText ?? field.filterPlaceholder ?? `All ${field.label || field.key}`;
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Column header text. An explicitly empty `label` means an EMPTY header — an
|
|
1255
|
+
* actions column headed `actions` (lowercase, as-keyed) is a leak, not a
|
|
1256
|
+
* default. Only fall back when no label was declared at all.
|
|
1257
|
+
*/
|
|
1258
|
+
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1259
|
+
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1260
|
+
|
|
1125
1261
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1262
|
+
const options: FilterOption[] = [];
|
|
1263
|
+
|
|
1264
|
+
// The way back to *unfiltered*. Without it the dropdown lists only the
|
|
1265
|
+
// values, so once a filter is picked the only way to clear it is the ✕ —
|
|
1266
|
+
// which isn't discoverable from inside the open list.
|
|
1267
|
+
//
|
|
1268
|
+
// It carries a SENTINEL, not `''`. bvn drops the ENTIRE option list if any
|
|
1269
|
+
// option's value is an empty string — not just that entry, the whole list —
|
|
1270
|
+
// so the obvious encoding of "no filter" silently empties the dropdown.
|
|
1271
|
+
// `handleSelectFilterChange` translates it back to `''`, which is already
|
|
1272
|
+
// how the filter map represents unset.
|
|
1273
|
+
options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
|
|
1274
|
+
|
|
1275
|
+
// An "Unassigned"-style option, so a select filter can express "has no value"
|
|
1276
|
+
// — a different thing from "no filter" above.
|
|
1277
|
+
if (field.filterNullText) {
|
|
1278
|
+
options.push({ value: filterNullValueFor(field), text: field.filterNullText });
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1126
1281
|
// If field has static filterOptions, use those
|
|
1127
1282
|
if (field.filterOptions && field.filterOptions.length > 0) {
|
|
1128
|
-
return field.filterOptions;
|
|
1283
|
+
return [...options, ...field.filterOptions];
|
|
1129
1284
|
}
|
|
1130
1285
|
|
|
1131
|
-
// Otherwise, check for server-provided values
|
|
1132
|
-
const serverValues =
|
|
1286
|
+
// Otherwise, check for server-provided values (keyed by the filter key)
|
|
1287
|
+
const serverValues =
|
|
1288
|
+
props.filterValues?.[filterKeyFor(field)] ?? apiFilterValues.value[filterKeyFor(field)];
|
|
1133
1289
|
|
|
1134
1290
|
if (serverValues && serverValues.length > 0) {
|
|
1135
|
-
|
|
1136
|
-
return serverValues.map(value => ({ value, text: value }));
|
|
1291
|
+
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1137
1292
|
}
|
|
1138
1293
|
|
|
1294
|
+
// No values to choose from — don't offer a lone "All x" that filters nothing.
|
|
1139
1295
|
return [];
|
|
1140
1296
|
};
|
|
1141
1297
|
|
|
@@ -1185,8 +1341,10 @@ watch(() => props.pagination?.per_page, (newPerPage) => {
|
|
|
1185
1341
|
|
|
1186
1342
|
// Watch for external filter changes (when filters prop is controlled by parent)
|
|
1187
1343
|
watch(() => props.filters, (newFilters, oldFilters) => {
|
|
1188
|
-
// Only
|
|
1189
|
-
|
|
1344
|
+
// Only when the consumer CONTROLS filters. Uncontrolled, the prop is just a
|
|
1345
|
+
// seed: `effectiveFilters` stays internal, so refreshing on a later prop
|
|
1346
|
+
// change would fetch with filters the table isn't actually showing (#110).
|
|
1347
|
+
if (!isControlled.filters || props.filters === undefined) return;
|
|
1190
1348
|
|
|
1191
1349
|
// Only refresh if filters actually changed
|
|
1192
1350
|
if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
|
|
@@ -1247,7 +1405,7 @@ const effectivePerPage = computed(() => {
|
|
|
1247
1405
|
}
|
|
1248
1406
|
|
|
1249
1407
|
// For API mode, use internal state (which gets updated immediately on change)
|
|
1250
|
-
// Don't use
|
|
1408
|
+
// Don't use providerPagination.per_page here because it's from the previous request
|
|
1251
1409
|
// and causes the select to flicker when user changes it
|
|
1252
1410
|
return internalPerPage.value;
|
|
1253
1411
|
});
|
|
@@ -1322,7 +1480,10 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1322
1480
|
? clientSidePagination.value?.total || 0
|
|
1323
1481
|
: isInertiaMode.value
|
|
1324
1482
|
? props.pagination?.total || 0
|
|
1325
|
-
|
|
1483
|
+
// Same source as the pager itself — reading apiPaginationMeta here
|
|
1484
|
+
// hid the selector for a custom provider whose pagination comes
|
|
1485
|
+
// from the `pagination` prop, while the page buttons still showed.
|
|
1486
|
+
: providerPagination.value?.total || 0;
|
|
1326
1487
|
|
|
1327
1488
|
return total >= smallestOption;
|
|
1328
1489
|
});
|
|
@@ -1331,7 +1492,7 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1331
1492
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1332
1493
|
return props.fields
|
|
1333
1494
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
1334
|
-
.map(field => field
|
|
1495
|
+
.map(field => filterKeyFor(field));
|
|
1335
1496
|
});
|
|
1336
1497
|
|
|
1337
1498
|
// Error state for API mode
|
|
@@ -1416,6 +1577,8 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
|
|
|
1416
1577
|
error?.message ||
|
|
1417
1578
|
'Failed to load data. Please try again.';
|
|
1418
1579
|
return [];
|
|
1580
|
+
} finally {
|
|
1581
|
+
providerHasRun.value = true;
|
|
1419
1582
|
}
|
|
1420
1583
|
};
|
|
1421
1584
|
|
|
@@ -1670,6 +1833,26 @@ try {
|
|
|
1670
1833
|
// Forward only the keyed edit slots the consumer actually provided, so
|
|
1671
1834
|
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1672
1835
|
// a real custom-value override.
|
|
1836
|
+
// Fields whose value actually belongs in the payload. A presentational field
|
|
1837
|
+
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
1838
|
+
const submittableEditFields = computed(() =>
|
|
1839
|
+
(props.editFields ?? []).filter((field) => field.submit !== false),
|
|
1840
|
+
);
|
|
1841
|
+
|
|
1842
|
+
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
1843
|
+
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
1844
|
+
// its key back into the form data after seeding. Strip them on the way out.
|
|
1845
|
+
const nonSubmittedFieldKeys = computed(
|
|
1846
|
+
() => new Set((props.editFields ?? []).filter((field) => field.submit === false).map((field) => field.key)),
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1849
|
+
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
1850
|
+
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
1851
|
+
return Object.fromEntries(
|
|
1852
|
+
Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
|
|
1853
|
+
);
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1673
1856
|
const tableSlots = useSlots();
|
|
1674
1857
|
|
|
1675
1858
|
/*
|
|
@@ -1789,13 +1972,15 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1789
1972
|
// seeding is synchronous — no interleaving between successive row opens.
|
|
1790
1973
|
if (!editForm.value) {
|
|
1791
1974
|
const formData: Record<string, any> = {};
|
|
1792
|
-
|
|
1975
|
+
// Presentational fields (submit: false) lay the form out; they hold
|
|
1976
|
+
// no data and must not be POSTed just because they were declared.
|
|
1977
|
+
submittableEditFields.value.forEach(field => {
|
|
1793
1978
|
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1794
1979
|
});
|
|
1795
1980
|
editForm.value = useForm(formData);
|
|
1796
1981
|
} else {
|
|
1797
1982
|
// Update existing form
|
|
1798
|
-
|
|
1983
|
+
submittableEditFields.value.forEach(field => {
|
|
1799
1984
|
editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1800
1985
|
});
|
|
1801
1986
|
editForm.value.clearErrors();
|
|
@@ -1826,7 +2011,7 @@ const fetchFullRecordForEdit = async (item: T) => {
|
|
|
1826
2011
|
|
|
1827
2012
|
const record = response.data?.data ?? response.data;
|
|
1828
2013
|
if (record && editForm.value) {
|
|
1829
|
-
|
|
2014
|
+
submittableEditFields.value.forEach(field => {
|
|
1830
2015
|
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
1831
2016
|
editForm.value.data[field.key] = record[field.key];
|
|
1832
2017
|
}
|
|
@@ -1866,13 +2051,13 @@ const handleCreateNew = () => {
|
|
|
1866
2051
|
|
|
1867
2052
|
if (!editForm.value) {
|
|
1868
2053
|
const formData: Record<string, any> = {};
|
|
1869
|
-
|
|
2054
|
+
submittableEditFields.value.forEach(field => {
|
|
1870
2055
|
formData[field.key] = field.default ?? '';
|
|
1871
2056
|
});
|
|
1872
2057
|
editForm.value = useForm(formData);
|
|
1873
2058
|
} else {
|
|
1874
2059
|
// Reset existing form to defaults
|
|
1875
|
-
|
|
2060
|
+
submittableEditFields.value.forEach(field => {
|
|
1876
2061
|
editForm.value.data[field.key] = field.default ?? '';
|
|
1877
2062
|
});
|
|
1878
2063
|
editForm.value.clearErrors();
|
|
@@ -1898,6 +2083,7 @@ const performSave = async () => {
|
|
|
1898
2083
|
if (isCreateMode.value && props.createUrl) {
|
|
1899
2084
|
try {
|
|
1900
2085
|
await editForm.value.post(props.createUrl, {
|
|
2086
|
+
transform: stripNonSubmittedFields,
|
|
1901
2087
|
onSuccess: (data: any) => {
|
|
1902
2088
|
createToast?.({
|
|
1903
2089
|
title: 'Success',
|
|
@@ -1950,6 +2136,7 @@ const performSave = async () => {
|
|
|
1950
2136
|
const url = props.editUrl.replace(':id', itemId);
|
|
1951
2137
|
|
|
1952
2138
|
await editForm.value.put(url, {
|
|
2139
|
+
transform: stripNonSubmittedFields,
|
|
1953
2140
|
onSuccess: (data: any) => {
|
|
1954
2141
|
// Show success toast
|
|
1955
2142
|
createToast?.({
|
|
@@ -2097,13 +2284,13 @@ defineExpose({
|
|
|
2097
2284
|
</script>
|
|
2098
2285
|
|
|
2099
2286
|
<style scoped>
|
|
2100
|
-
/*
|
|
2287
|
+
/* Rows that do something on click look like it — see `rowsAreInteractive`. */
|
|
2101
2288
|
:deep(tbody tr) {
|
|
2102
|
-
cursor: v-bind(
|
|
2289
|
+
cursor: v-bind(rowCursor);
|
|
2103
2290
|
}
|
|
2104
2291
|
|
|
2105
2292
|
:deep(tbody tr:hover) {
|
|
2106
|
-
background-color: v-bind(
|
|
2293
|
+
background-color: v-bind(rowHoverBackground);
|
|
2107
2294
|
}
|
|
2108
2295
|
|
|
2109
2296
|
/* Improve pagination button sizing to match form controls */
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
<div class="dx-user-avatar">
|
|
16
16
|
<!-- `.user-avatar` (not a `dx-`-prefixed name) because it's the documented
|
|
17
17
|
theming hook consumers already override — see the theming guide. -->
|
|
18
|
-
|
|
18
|
+
<!-- The initial is decorative: it tells a screen-reader user nothing they
|
|
19
|
+
don't know, and it would otherwise leak into the accessible name of
|
|
20
|
+
whatever contains the avatar (the navbar's user-menu trigger). The
|
|
21
|
+
badge's label below stays announced. -->
|
|
22
|
+
<span class="user-avatar" aria-hidden="true">
|
|
19
23
|
<!-- @slot Replaces the initial inside the disc (e.g. a photo or an icon). -->
|
|
20
24
|
<slot>{{ resolvedInitial }}</slot>
|
|
21
25
|
</span>
|