@omnitend/dashboard-for-laravel 0.23.0 → 0.24.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/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXTable.vue.d.ts +18 -0
- package/dist/dashboard-for-laravel.js +8608 -8549
- 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 +2 -2
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/js/components/extended/DXField.vue +31 -0
- package/resources/js/components/extended/DXTable.vue +220 -68
- 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
83
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
84
84
|
size="sm"
|
|
85
85
|
open-on-focus
|
|
86
|
-
@update:model-value="handleFilterChange(field
|
|
86
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
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
179
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
180
180
|
size="sm"
|
|
181
181
|
open-on-focus
|
|
182
|
-
@update:model-value="handleFilterChange(field
|
|
182
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
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
276
|
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
277
277
|
size="sm"
|
|
278
278
|
open-on-focus
|
|
279
|
-
@update:model-value="handleFilterChange(field
|
|
279
|
+
@update:model-value="handleFilterChange(filterKeyFor(field), ($event ?? '') as string)"
|
|
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,27 @@ 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;
|
|
667
688
|
sortable?: boolean;
|
|
668
689
|
hint?: string;
|
|
669
690
|
filter?: FilterType;
|
|
@@ -990,20 +1011,37 @@ const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.l
|
|
|
990
1011
|
*/
|
|
991
1012
|
const instance = getCurrentInstance();
|
|
992
1013
|
|
|
993
|
-
const
|
|
1014
|
+
const hasListener = (event: string): boolean => {
|
|
994
1015
|
const vnodeProps = instance?.vnode.props ?? {};
|
|
995
|
-
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
|
|
1016
|
+
const toHandlerKey = (name: string) => `on${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
1017
|
+
// `v-model:per-page` / `@row-clicked` compile to camelCase handler keys, but
|
|
1018
|
+
// a hand-written hyphenated listener stays hyphenated — accept either.
|
|
1019
|
+
const hyphenated = event.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
1020
|
+
return toHandlerKey(event) in vnodeProps || toHandlerKey(hyphenated) in vnodeProps;
|
|
999
1021
|
};
|
|
1000
1022
|
|
|
1001
1023
|
const isControlled = {
|
|
1002
|
-
perPage:
|
|
1003
|
-
sortBy:
|
|
1004
|
-
filters:
|
|
1024
|
+
perPage: hasListener('update:perPage'),
|
|
1025
|
+
sortBy: hasListener('update:sortBy'),
|
|
1026
|
+
filters: hasListener('update:filters'),
|
|
1005
1027
|
};
|
|
1006
1028
|
|
|
1029
|
+
/*
|
|
1030
|
+
* Rows are interactive when clicking one does something: the built-in edit
|
|
1031
|
+
* modal (`editFields`) opens, or the consumer is listening for `row-clicked`.
|
|
1032
|
+
* The cursor and hover affordance follow from that — DXTable already knows,
|
|
1033
|
+
* so consumers shouldn't have to reach into `:deep(tbody tr)` to say it (#107).
|
|
1034
|
+
* The listener case was the gap: only `editFields` used to count.
|
|
1035
|
+
*/
|
|
1036
|
+
const rowsAreInteractive = computed(
|
|
1037
|
+
() => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const rowCursor = computed(() => (rowsAreInteractive.value ? 'pointer' : 'default'));
|
|
1041
|
+
const rowHoverBackground = computed(() =>
|
|
1042
|
+
rowsAreInteractive.value ? 'var(--bs-table-hover-bg)' : 'inherit',
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1007
1045
|
// Internal sortBy state, seeded from the prop when it's an initial value.
|
|
1008
1046
|
const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
|
|
1009
1047
|
|
|
@@ -1030,6 +1068,52 @@ const hasActiveFilters = computed(() => {
|
|
|
1030
1068
|
// API mode pagination metadata (extracted from responses)
|
|
1031
1069
|
const apiPaginationMeta = ref<PaginationData | null>(null);
|
|
1032
1070
|
|
|
1071
|
+
/**
|
|
1072
|
+
* Pagination shown in provider mode.
|
|
1073
|
+
*
|
|
1074
|
+
* Only the built-in `apiUrl` provider can populate `apiPaginationMeta` — it's
|
|
1075
|
+
* the one that knows to read `response.data.pagination`. A CUSTOM provider
|
|
1076
|
+
* returns rows and nothing else, so the pager simply never rendered: a table
|
|
1077
|
+
* with no pagination and no warning, which is why a page needing custom request
|
|
1078
|
+
* shaping quietly lost its pager (#106). A custom provider now takes its
|
|
1079
|
+
* pagination from the `pagination` prop, and says so when it's missing.
|
|
1080
|
+
*/
|
|
1081
|
+
// `pagination` carries a default from withDefaults, so props alone can't tell
|
|
1082
|
+
// whether the consumer actually passed one — ask the vnode. (Gating on
|
|
1083
|
+
// `total > 0` instead would treat a legitimately empty page as "no pagination".)
|
|
1084
|
+
const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
|
|
1085
|
+
|
|
1086
|
+
const providerPagination = computed<PaginationData | null>(() => {
|
|
1087
|
+
if (apiPaginationMeta.value) return apiPaginationMeta.value;
|
|
1088
|
+
if (props.provider && paginationWasProvided && props.pagination) {
|
|
1089
|
+
return props.pagination;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
/*
|
|
1095
|
+
* Loud, once, rather than a silently pager-less table — but only AFTER the
|
|
1096
|
+
* provider has actually run, and judged on `providerPagination` (not the raw
|
|
1097
|
+
* apiPaginationMeta). Warning at setup would fire on every consumer whose
|
|
1098
|
+
* `pagination` prop arrives with the response rather than before it.
|
|
1099
|
+
*/
|
|
1100
|
+
const providerHasRun = ref(false);
|
|
1101
|
+
let warnedAboutProviderPagination = false;
|
|
1102
|
+
watch(
|
|
1103
|
+
() => [props.provider, props.showPagination, providerPagination.value, providerHasRun.value] as const,
|
|
1104
|
+
([provider, showPagination, pagination, hasRun]) => {
|
|
1105
|
+
if (warnedAboutProviderPagination || !hasRun) return;
|
|
1106
|
+
if (provider && showPagination && !pagination) {
|
|
1107
|
+
warnedAboutProviderPagination = true;
|
|
1108
|
+
// eslint-disable-next-line no-console
|
|
1109
|
+
console.warn(
|
|
1110
|
+
'[DXTable] A custom `provider` cannot report its own pagination, so no pager will render. ' +
|
|
1111
|
+
'Pass the page metadata via the `pagination` prop, or set `:show-pagination="false"` to hide the pager deliberately.',
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1033
1117
|
// ============================================
|
|
1034
1118
|
// Client-Side Mode: Filtering, Sorting, Pagination
|
|
1035
1119
|
// ============================================
|
|
@@ -1051,8 +1135,23 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1051
1135
|
return props.items.filter(item => {
|
|
1052
1136
|
return filterKeys.every(key => {
|
|
1053
1137
|
const filterValue = filters[key].trim().toLowerCase();
|
|
1054
|
-
|
|
1055
|
-
const
|
|
1138
|
+
// Resolve by the FILTER key, which may differ from the column's key.
|
|
1139
|
+
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
1140
|
+
// `filterKey` names the param the SERVER filters on; a local row may
|
|
1141
|
+
// not carry it at all (a `customer_name` row filtered on
|
|
1142
|
+
// `customer_id`). Match on the filter key when the row has it,
|
|
1143
|
+
// otherwise fall back to the column's own value — which is also the
|
|
1144
|
+
// one the user can actually see and type against.
|
|
1145
|
+
const itemValue =
|
|
1146
|
+
field && key in (item as any)
|
|
1147
|
+
? (item as any)[key]
|
|
1148
|
+
: (item as any)[field?.key ?? key];
|
|
1149
|
+
|
|
1150
|
+
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
1151
|
+
// MATCHES an absent value rather than failing on it.
|
|
1152
|
+
if (field?.filterNullText && filters[key] === filterNullValueFor(field)) {
|
|
1153
|
+
return itemValue === null || itemValue === undefined || itemValue === '';
|
|
1154
|
+
}
|
|
1056
1155
|
|
|
1057
1156
|
if (itemValue === null || itemValue === undefined) {
|
|
1058
1157
|
return false;
|
|
@@ -1122,21 +1221,43 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1122
1221
|
const apiFilterValues = ref<Record<string, string[]>>({});
|
|
1123
1222
|
|
|
1124
1223
|
// Computed: Get effective filter options for a field
|
|
1224
|
+
// The key a column's filter is SENT under, which is not always the column's own
|
|
1225
|
+
// key: a display column can filter on a different server param via `filterKey`.
|
|
1226
|
+
const filterKeyFor = (field: TableField): string => field.filterKey ?? field.key;
|
|
1227
|
+
|
|
1228
|
+
// The value that means "has no value" for a select filter (#106).
|
|
1229
|
+
const filterNullValueFor = (field: TableField): string => field.filterNullValue ?? 'null';
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Column header text. An explicitly empty `label` means an EMPTY header — an
|
|
1233
|
+
* actions column headed `actions` (lowercase, as-keyed) is a leak, not a
|
|
1234
|
+
* default. Only fall back when no label was declared at all.
|
|
1235
|
+
*/
|
|
1236
|
+
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1237
|
+
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1238
|
+
|
|
1125
1239
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1240
|
+
const options: FilterOption[] = [];
|
|
1241
|
+
|
|
1242
|
+
// An "Unassigned"-style option, so a select filter can express "no value".
|
|
1243
|
+
if (field.filterNullText) {
|
|
1244
|
+
options.push({ value: filterNullValueFor(field), text: field.filterNullText });
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1126
1247
|
// If field has static filterOptions, use those
|
|
1127
1248
|
if (field.filterOptions && field.filterOptions.length > 0) {
|
|
1128
|
-
return field.filterOptions;
|
|
1249
|
+
return [...options, ...field.filterOptions];
|
|
1129
1250
|
}
|
|
1130
1251
|
|
|
1131
|
-
// Otherwise, check for server-provided values
|
|
1132
|
-
const serverValues =
|
|
1252
|
+
// Otherwise, check for server-provided values (keyed by the filter key)
|
|
1253
|
+
const serverValues =
|
|
1254
|
+
props.filterValues?.[filterKeyFor(field)] ?? apiFilterValues.value[filterKeyFor(field)];
|
|
1133
1255
|
|
|
1134
1256
|
if (serverValues && serverValues.length > 0) {
|
|
1135
|
-
|
|
1136
|
-
return serverValues.map(value => ({ value, text: value }));
|
|
1257
|
+
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1137
1258
|
}
|
|
1138
1259
|
|
|
1139
|
-
return
|
|
1260
|
+
return options;
|
|
1140
1261
|
};
|
|
1141
1262
|
|
|
1142
1263
|
// LocalStorage key for perPage preference
|
|
@@ -1185,8 +1306,10 @@ watch(() => props.pagination?.per_page, (newPerPage) => {
|
|
|
1185
1306
|
|
|
1186
1307
|
// Watch for external filter changes (when filters prop is controlled by parent)
|
|
1187
1308
|
watch(() => props.filters, (newFilters, oldFilters) => {
|
|
1188
|
-
// Only
|
|
1189
|
-
|
|
1309
|
+
// Only when the consumer CONTROLS filters. Uncontrolled, the prop is just a
|
|
1310
|
+
// seed: `effectiveFilters` stays internal, so refreshing on a later prop
|
|
1311
|
+
// change would fetch with filters the table isn't actually showing (#110).
|
|
1312
|
+
if (!isControlled.filters || props.filters === undefined) return;
|
|
1190
1313
|
|
|
1191
1314
|
// Only refresh if filters actually changed
|
|
1192
1315
|
if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
|
|
@@ -1247,7 +1370,7 @@ const effectivePerPage = computed(() => {
|
|
|
1247
1370
|
}
|
|
1248
1371
|
|
|
1249
1372
|
// For API mode, use internal state (which gets updated immediately on change)
|
|
1250
|
-
// Don't use
|
|
1373
|
+
// Don't use providerPagination.per_page here because it's from the previous request
|
|
1251
1374
|
// and causes the select to flicker when user changes it
|
|
1252
1375
|
return internalPerPage.value;
|
|
1253
1376
|
});
|
|
@@ -1322,7 +1445,10 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1322
1445
|
? clientSidePagination.value?.total || 0
|
|
1323
1446
|
: isInertiaMode.value
|
|
1324
1447
|
? props.pagination?.total || 0
|
|
1325
|
-
|
|
1448
|
+
// Same source as the pager itself — reading apiPaginationMeta here
|
|
1449
|
+
// hid the selector for a custom provider whose pagination comes
|
|
1450
|
+
// from the `pagination` prop, while the page buttons still showed.
|
|
1451
|
+
: providerPagination.value?.total || 0;
|
|
1326
1452
|
|
|
1327
1453
|
return total >= smallestOption;
|
|
1328
1454
|
});
|
|
@@ -1331,7 +1457,7 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1331
1457
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1332
1458
|
return props.fields
|
|
1333
1459
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
1334
|
-
.map(field => field
|
|
1460
|
+
.map(field => filterKeyFor(field));
|
|
1335
1461
|
});
|
|
1336
1462
|
|
|
1337
1463
|
// Error state for API mode
|
|
@@ -1416,6 +1542,8 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
|
|
|
1416
1542
|
error?.message ||
|
|
1417
1543
|
'Failed to load data. Please try again.';
|
|
1418
1544
|
return [];
|
|
1545
|
+
} finally {
|
|
1546
|
+
providerHasRun.value = true;
|
|
1419
1547
|
}
|
|
1420
1548
|
};
|
|
1421
1549
|
|
|
@@ -1670,6 +1798,26 @@ try {
|
|
|
1670
1798
|
// Forward only the keyed edit slots the consumer actually provided, so
|
|
1671
1799
|
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1672
1800
|
// a real custom-value override.
|
|
1801
|
+
// Fields whose value actually belongs in the payload. A presentational field
|
|
1802
|
+
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
1803
|
+
const submittableEditFields = computed(() =>
|
|
1804
|
+
(props.editFields ?? []).filter((field) => field.submit !== false),
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
1808
|
+
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
1809
|
+
// its key back into the form data after seeding. Strip them on the way out.
|
|
1810
|
+
const nonSubmittedFieldKeys = computed(
|
|
1811
|
+
() => new Set((props.editFields ?? []).filter((field) => field.submit === false).map((field) => field.key)),
|
|
1812
|
+
);
|
|
1813
|
+
|
|
1814
|
+
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
1815
|
+
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
1816
|
+
return Object.fromEntries(
|
|
1817
|
+
Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
|
|
1818
|
+
);
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1673
1821
|
const tableSlots = useSlots();
|
|
1674
1822
|
|
|
1675
1823
|
/*
|
|
@@ -1789,13 +1937,15 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1789
1937
|
// seeding is synchronous — no interleaving between successive row opens.
|
|
1790
1938
|
if (!editForm.value) {
|
|
1791
1939
|
const formData: Record<string, any> = {};
|
|
1792
|
-
|
|
1940
|
+
// Presentational fields (submit: false) lay the form out; they hold
|
|
1941
|
+
// no data and must not be POSTed just because they were declared.
|
|
1942
|
+
submittableEditFields.value.forEach(field => {
|
|
1793
1943
|
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1794
1944
|
});
|
|
1795
1945
|
editForm.value = useForm(formData);
|
|
1796
1946
|
} else {
|
|
1797
1947
|
// Update existing form
|
|
1798
|
-
|
|
1948
|
+
submittableEditFields.value.forEach(field => {
|
|
1799
1949
|
editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1800
1950
|
});
|
|
1801
1951
|
editForm.value.clearErrors();
|
|
@@ -1826,7 +1976,7 @@ const fetchFullRecordForEdit = async (item: T) => {
|
|
|
1826
1976
|
|
|
1827
1977
|
const record = response.data?.data ?? response.data;
|
|
1828
1978
|
if (record && editForm.value) {
|
|
1829
|
-
|
|
1979
|
+
submittableEditFields.value.forEach(field => {
|
|
1830
1980
|
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
1831
1981
|
editForm.value.data[field.key] = record[field.key];
|
|
1832
1982
|
}
|
|
@@ -1866,13 +2016,13 @@ const handleCreateNew = () => {
|
|
|
1866
2016
|
|
|
1867
2017
|
if (!editForm.value) {
|
|
1868
2018
|
const formData: Record<string, any> = {};
|
|
1869
|
-
|
|
2019
|
+
submittableEditFields.value.forEach(field => {
|
|
1870
2020
|
formData[field.key] = field.default ?? '';
|
|
1871
2021
|
});
|
|
1872
2022
|
editForm.value = useForm(formData);
|
|
1873
2023
|
} else {
|
|
1874
2024
|
// Reset existing form to defaults
|
|
1875
|
-
|
|
2025
|
+
submittableEditFields.value.forEach(field => {
|
|
1876
2026
|
editForm.value.data[field.key] = field.default ?? '';
|
|
1877
2027
|
});
|
|
1878
2028
|
editForm.value.clearErrors();
|
|
@@ -1898,6 +2048,7 @@ const performSave = async () => {
|
|
|
1898
2048
|
if (isCreateMode.value && props.createUrl) {
|
|
1899
2049
|
try {
|
|
1900
2050
|
await editForm.value.post(props.createUrl, {
|
|
2051
|
+
transform: stripNonSubmittedFields,
|
|
1901
2052
|
onSuccess: (data: any) => {
|
|
1902
2053
|
createToast?.({
|
|
1903
2054
|
title: 'Success',
|
|
@@ -1950,6 +2101,7 @@ const performSave = async () => {
|
|
|
1950
2101
|
const url = props.editUrl.replace(':id', itemId);
|
|
1951
2102
|
|
|
1952
2103
|
await editForm.value.put(url, {
|
|
2104
|
+
transform: stripNonSubmittedFields,
|
|
1953
2105
|
onSuccess: (data: any) => {
|
|
1954
2106
|
// Show success toast
|
|
1955
2107
|
createToast?.({
|
|
@@ -2097,13 +2249,13 @@ defineExpose({
|
|
|
2097
2249
|
</script>
|
|
2098
2250
|
|
|
2099
2251
|
<style scoped>
|
|
2100
|
-
/*
|
|
2252
|
+
/* Rows that do something on click look like it — see `rowsAreInteractive`. */
|
|
2101
2253
|
:deep(tbody tr) {
|
|
2102
|
-
cursor: v-bind(
|
|
2254
|
+
cursor: v-bind(rowCursor);
|
|
2103
2255
|
}
|
|
2104
2256
|
|
|
2105
2257
|
:deep(tbody tr:hover) {
|
|
2106
|
-
background-color: v-bind(
|
|
2258
|
+
background-color: v-bind(rowHoverBackground);
|
|
2107
2259
|
}
|
|
2108
2260
|
|
|
2109
2261
|
/* Improve pagination button sizing to match form controls */
|
|
@@ -118,6 +118,17 @@ export interface FieldDefinition {
|
|
|
118
118
|
/** Re-run `optionsLoader` whenever the form model changes. */
|
|
119
119
|
reloadOptionsOnChange?: boolean;
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* For `select` fields: render a **searchable** select — type to filter a
|
|
123
|
+
* long option list, while the model still holds `option.value` (an id).
|
|
124
|
+
*
|
|
125
|
+
* A plain `select` is fine for ten options and unusable for hundreds; the
|
|
126
|
+
* `autocomplete` type filters but models the *typed text*, so it can't back
|
|
127
|
+
* a foreign key (picking "Tesco" gives you `"Tesco"`, not `37`). This is the
|
|
128
|
+
* combination high-frequency data entry against a long list needs.
|
|
129
|
+
*/
|
|
130
|
+
searchable?: boolean;
|
|
131
|
+
|
|
121
132
|
/** Number of rows for textarea (default: 3) */
|
|
122
133
|
rows?: number;
|
|
123
134
|
|
|
@@ -318,6 +329,18 @@ export interface FieldDefinition {
|
|
|
318
329
|
*/
|
|
319
330
|
revealable?: boolean;
|
|
320
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Include this field's value in the submitted payload. Default `true`.
|
|
334
|
+
*
|
|
335
|
+
* Set `false` for a **presentational** field — a header, an alert, an
|
|
336
|
+
* explanatory block rendered via `span` — that exists to lay out the form,
|
|
337
|
+
* not to hold data. Without it, `DXTable`'s edit modal seeds its form from
|
|
338
|
+
* every `editFields` key, so those decorative fields are POSTed alongside
|
|
339
|
+
* the real ones (an empty string, an empty array) purely because they were
|
|
340
|
+
* declared.
|
|
341
|
+
*/
|
|
342
|
+
submit?: boolean;
|
|
343
|
+
|
|
321
344
|
/**
|
|
322
345
|
* Conditionally show or hide this field. When omitted the field is
|
|
323
346
|
* always visible. Boolean or a function of the form model; evaluated
|