@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.
@@ -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
83
  :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
84
84
  size="sm"
85
85
  open-on-focus
86
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.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
179
  :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
180
180
  size="sm"
181
181
  open-on-focus
182
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.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
276
  :placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
277
277
  size="sm"
278
278
  open-on-focus
279
- @update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
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.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,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 hasUpdateListener = (name: string): boolean => {
1014
+ const hasListener = (event: string): boolean => {
994
1015
  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;
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: hasUpdateListener('perPage'),
1003
- sortBy: hasUpdateListener('sortBy'),
1004
- filters: hasUpdateListener('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
- const field = props.fields.find(f => f.key === key);
1055
- const itemValue = (item as any)[key];
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 = props.filterValues?.[field.key] || apiFilterValues.value[field.key];
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
- // Convert string array to FilterOption array
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 trigger refresh if filters prop is being used (not internal state)
1189
- if (props.filters === undefined) return;
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 apiPaginationMeta.per_page here because it's from the previous request
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
- : apiPaginationMeta.value?.total || 0;
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.key);
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
- props.editFields!.forEach(field => {
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
- props.editFields.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- props.editFields!.forEach(field => {
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
- /* Add pointer cursor to table rows when editFields is enabled */
2252
+ /* Rows that do something on click look like it — see `rowsAreInteractive`. */
2101
2253
  :deep(tbody tr) {
2102
- cursor: v-bind('editFields && editFields.length > 0 ? "pointer" : "default"');
2254
+ cursor: v-bind(rowCursor);
2103
2255
  }
2104
2256
 
2105
2257
  :deep(tbody tr:hover) {
2106
- background-color: v-bind('editFields && editFields.length > 0 ? "var(--bs-table-hover-bg)" : "inherit"');
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