@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.
@@ -69,40 +69,40 @@
69
69
  <!-- Text Filter -->
70
70
  <DFormInput
71
71
  v-if="field.filter === 'text'"
72
- :model-value="effectiveFilters[field.key] || ''"
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.key, $event as string)"
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.key] || ''"
81
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
82
82
  :options="getFieldFilterOptions(field)"
83
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
83
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
84
84
  size="sm"
85
85
  open-on-focus
86
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.key] || ''"
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.key, $event as string)"
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.key] || ''"
102
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
103
103
  type="date"
104
104
  size="sm"
105
- @update:model-value="handleFilterChange(field.key, $event as string)"
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 || field.key }}</div>
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.key] || ''"
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.key, $event as string)"
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.key] || ''"
177
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
178
178
  :options="getFieldFilterOptions(field)"
179
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
179
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
180
180
  size="sm"
181
181
  open-on-focus
182
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.key] || ''"
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.key, $event as string)"
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.key] || ''"
198
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
199
199
  type="date"
200
200
  size="sm"
201
- @update:model-value="handleFilterChange(field.key, $event as string)"
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 || field.key }}</div>
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.key] || ''"
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.key, $event as string)"
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.key] || ''"
274
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
275
275
  :options="getFieldFilterOptions(field)"
276
- :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
276
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
277
277
  size="sm"
278
278
  open-on-focus
279
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.key] || ''"
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.key, $event as string)"
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.key] || ''"
295
+ :model-value="effectiveFilters[filterKeyFor(field)] || ''"
296
296
  type="date"
297
297
  size="sm"
298
- @update:model-value="handleFilterChange(field.key, $event as string)"
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 || field.key }}</div>
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 && apiPaginationMeta" class="mt-3">
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 && apiPaginationMeta.total > apiPaginationMeta.per_page"
440
- :model-value="apiPaginationMeta.current_page"
441
- :total-rows="apiPaginationMeta.total"
442
- :per-page="apiPaginationMeta.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="apiPaginationMeta.total > apiPaginationMeta.per_page">
466
- {{ apiPaginationMeta.from }} to {{ apiPaginationMeta.to }} out of {{ apiPaginationMeta.total }} {{ apiPaginationMeta.total === 1 ? singularItemName : pluralItemName }}.
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="apiPaginationMeta.total === 1">
469
- {{ apiPaginationMeta.total }} {{ singularItemName }}.
468
+ <template v-else-if="providerPagination.total === 1">
469
+ {{ providerPagination.total }} {{ singularItemName }}.
470
470
  </template>
471
471
  <template v-else>
472
- {{ apiPaginationMeta.total }} {{ pluralItemName }}.
472
+ {{ providerPagination.total }} {{ pluralItemName }}.
473
473
  </template>
474
474
  </div>
475
- <div v-if="hasActiveFilters && apiPaginationMeta.total_unfiltered">
476
- <small>Filtered from {{ apiPaginationMeta.total_unfiltered }} {{ apiPaginationMeta.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
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, 25, 50, 100],
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 hasUpdateListener = (name: string): boolean => {
1022
+ const hasListener = (event: string): boolean => {
994
1023
  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;
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: hasUpdateListener('perPage'),
1003
- sortBy: hasUpdateListener('sortBy'),
1004
- filters: hasUpdateListener('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
- const field = props.fields.find(f => f.key === key);
1055
- const itemValue = (item as any)[key];
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 = props.filterValues?.[field.key] || apiFilterValues.value[field.key];
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
- // Convert string array to FilterOption array
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 trigger refresh if filters prop is being used (not internal state)
1189
- if (props.filters === undefined) return;
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 apiPaginationMeta.per_page here because it's from the previous request
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
- : apiPaginationMeta.value?.total || 0;
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.key);
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
- props.editFields!.forEach(field => {
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
- props.editFields.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- /* Add pointer cursor to table rows when editFields is enabled */
2287
+ /* Rows that do something on click look like it — see `rowsAreInteractive`. */
2101
2288
  :deep(tbody tr) {
2102
- cursor: v-bind('editFields && editFields.length > 0 ? "pointer" : "default"');
2289
+ cursor: v-bind(rowCursor);
2103
2290
  }
2104
2291
 
2105
2292
  :deep(tbody tr:hover) {
2106
- background-color: v-bind('editFields && editFields.length > 0 ? "var(--bs-table-hover-bg)" : "inherit"');
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
- <span class="user-avatar">
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>