@omnitend/dashboard-for-laravel 0.32.0 → 0.33.1

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.
@@ -229,6 +229,65 @@
229
229
  <DFormText v-if="field.help">{{ field.help }}</DFormText>
230
230
  </div>
231
231
 
232
+ <!-- Switch-list (#160): a list of labelled boolean toggles as config, not
233
+ markup. One real DFormGroup per option row — the ONLY correct source of
234
+ the label/content grid (hardcoding col classes is exactly the drift
235
+ this field exists to end) — with the option text in the label column
236
+ and a compact track switch (not the filled-box DXSwitch, too heavy in
237
+ a dense list) in the content column. The model is an array of selected
238
+ option values, like checkbox-group. -->
239
+ <div v-else-if="field.type === 'switch-list'" :class="field.class || 'mb-3'">
240
+ <div
241
+ v-if="!hideLabel && field.label"
242
+ class="dx-switch-list-heading text-muted fw-semibold"
243
+ >
244
+ <DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
245
+ </div>
246
+ <div
247
+ v-for="option in resolvedOptions ?? []"
248
+ :key="String(option.value)"
249
+ class="dx-switch-list-row"
250
+ >
251
+ <DFormGroup v-bind="switchListRowAttrs" class="mb-0 align-items-center">
252
+ <template #label>
253
+ <DXFieldLabel
254
+ :label="option.text"
255
+ :title="option.description"
256
+ />
257
+ </template>
258
+ <div class="dx-switch-list-control">
259
+ <DFormCheckbox
260
+ switch
261
+ :class="field.switchVariant === 'neutral' ? 'switch-neutral' : undefined"
262
+ :model-value="switchListIsOn(option)"
263
+ :disabled="isDisabled || isReadonly || isPlaintext || option.disabled === true"
264
+ :aria-label="option.text"
265
+ @update:model-value="setSwitchListOn(option, $event === true)"
266
+ />
267
+ <!--
268
+ @slot Extra per-row content beside a switch-list row's toggle (e.g. a notes input the consumer binds to their own model).
269
+ @binding {FieldOption} option The option this row renders.
270
+ @binding {boolean} on Whether the row's switch is currently on.
271
+ -->
272
+ <slot
273
+ name="switch-list-item"
274
+ :option="option"
275
+ :on="switchListIsOn(option)"
276
+ />
277
+ </div>
278
+ </DFormGroup>
279
+ </div>
280
+
281
+ <DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
282
+ {{ form.getError(errorKey) }}
283
+ </DFormInvalidFeedback>
284
+ <slot name="info" :field="field" :model="model" />
285
+ <DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
286
+ <slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
287
+ </DFormText>
288
+ <DFormText v-if="field.help">{{ field.help }}</DFormText>
289
+ </div>
290
+
232
291
  <!-- Repeater: nested, repeatable sub-form -->
233
292
  <div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
234
293
  <DFormGroup v-bind="repeaterHorizontalAttrs">
@@ -402,35 +461,31 @@
402
461
  />
403
462
  </div>
404
463
 
405
- <!-- Currency / percentage: numeric input with an affix -->
406
- <DInputGroup v-else-if="field.type === 'currency' || field.type === 'percentage'">
407
- <template v-if="field.type === 'currency'" #prepend>
408
- <span class="input-group-text">{{ field.currencySymbol || "£" }}</span>
409
- </template>
464
+ <!-- Currency: the DXCurrencyInput leaf (#152) owns the symbol prepend,
465
+ the blur-padded display (#69), empty -> null, and the minor-units
466
+ model scaling (#116). -->
467
+ <DXCurrencyInput
468
+ v-else-if="field.type === 'currency'"
469
+ :model-value="fieldValue"
470
+ :currency-symbol="field.currencySymbol || '£'"
471
+ :decimals="field.decimals ?? 2"
472
+ :minor-units="field.minorUnits ?? false"
473
+ :step="field.step"
474
+ :required="field.required"
475
+ :placeholder="field.placeholder"
476
+ :min="field.min"
477
+ :max="field.max"
478
+ :state="fieldState"
479
+ :disabled="isDisabled"
480
+ :readonly="isReadonly || isPlaintext"
481
+ :plaintext="isPlaintext"
482
+ v-bind="inputPropsWithGuards"
483
+ @update:model-value="setValue($event)"
484
+ />
410
485
 
411
- <!-- Currency: the displayed text is padded to the minor-unit
412
- precision on blur/seed (e.g. `3.8` -> `3.80`); the model stays
413
- a plain number and typing is never reformatted mid-edit. -->
486
+ <!-- Percentage: numeric input with an affix -->
487
+ <DInputGroup v-else-if="field.type === 'percentage'">
414
488
  <DFormInput
415
- v-if="field.type === 'currency'"
416
- :model-value="currencyDisplayValue"
417
- type="number"
418
- :required="field.required"
419
- :placeholder="field.placeholder"
420
- :step="field.step ?? '0.01'"
421
- :min="field.min"
422
- :max="field.max"
423
- :state="fieldState"
424
- :disabled="isDisabled"
425
- :readonly="isReadonly || isPlaintext"
426
- :plaintext="isPlaintext"
427
- v-bind="inputPropsWithGuards"
428
- @input="handleCurrencyInput"
429
- @focus="handleCurrencyFocus"
430
- @blur="handleCurrencyBlur"
431
- />
432
- <DFormInput
433
- v-else
434
489
  v-model="numericInputValue"
435
490
  type="number"
436
491
  :required="field.required"
@@ -445,7 +500,7 @@
445
500
  v-bind="inputPropsWithGuards"
446
501
  />
447
502
 
448
- <template v-if="field.type === 'percentage'" #append>
503
+ <template #append>
449
504
  <span class="input-group-text">%</span>
450
505
  </template>
451
506
  </DInputGroup>
@@ -576,6 +631,7 @@ import DInputGroup from "../base/DInputGroup.vue";
576
631
  import DAutocomplete from "../base/DAutocomplete.vue";
577
632
  import DButton from "../base/DButton.vue";
578
633
  import DXFieldLabel from "./DXFieldLabel.vue";
634
+ import DXCurrencyInput from "./DXCurrencyInput.vue";
579
635
  import type { UseFormReturn } from "../../composables/useForm";
580
636
  import type { FieldDefinition, FieldOption, FieldType, LabelCols } from "../../types";
581
637
  import { getByPath, setByPath } from "../../utils/objectPath";
@@ -849,59 +905,51 @@ function setValue(value: any): void {
849
905
  props.form.clearError(errorKey.value);
850
906
  }
851
907
 
852
- // ————————————————— currency display formatting (#69)
853
-
854
- // The model stays a plain number throughout; only the input's *displayed*
855
- // text is padded to the minor-unit precision (`3.8` -> `3.80`), on blur and on
856
- // initial seed. Reformatting reactively on every keystroke (rather than on
857
- // blur) would fight the user mid-edit — e.g. rounding "3." to "3.00" before
858
- // they've typed the fractional digits — so a local ref tracks the shown text
859
- // independently of the numeric model, and is only resynced from the model
860
- // while the input isn't focused.
861
- const currencyDecimals = computed(() => props.field.decimals ?? 2);
862
- const isCurrencyFocused = ref(false);
863
- const currencyDisplayValue = ref("");
864
-
865
- function formatCurrency(value: any): string {
866
- if (value === null || value === undefined || value === "") return "";
867
- const num = Number(value);
868
- if (!Number.isFinite(num)) return String(value);
869
- return num.toFixed(currencyDecimals.value);
870
- }
908
+ const fieldState = computed(() => props.form.getState(errorKey.value));
871
909
 
872
- watch(
873
- () => fieldValue.value,
874
- (value) => {
875
- if (isCurrencyFocused.value) return;
876
- currencyDisplayValue.value = formatCurrency(value);
877
- },
878
- { immediate: true },
910
+ const resolvedLabel = computed(
911
+ () => resolveMaybe(props.field.label) ?? props.field.key,
879
912
  );
880
913
 
881
- function handleCurrencyFocus(): void {
882
- isCurrencyFocused.value = true;
883
- }
914
+ const resolvedHint = computed(() => resolveMaybe(props.field.hint));
884
915
 
885
- function handleCurrencyInput(event: Event): void {
886
- const raw = (event.target as HTMLInputElement).value;
887
- currencyDisplayValue.value = raw;
888
- setValue(raw);
889
- }
916
+ const resolvedInfo = computed(() => resolveMaybe(props.field.info));
890
917
 
891
- function handleCurrencyBlur(): void {
892
- isCurrencyFocused.value = false;
893
- currencyDisplayValue.value = formatCurrency(fieldValue.value);
894
- }
918
+ // ————————————————— switch-list (#160)
895
919
 
896
- const fieldState = computed(() => props.form.getState(errorKey.value));
920
+ // Row grid for switch-list option rows. Deliberately NOT `horizontalAttrs`:
921
+ // that computed empties itself under `hideLabel`, which is right for the
922
+ // FIELD label but would collapse every option row's label column too —
923
+ // `hideLabel` on a switch-list should drop only the section heading.
924
+ const switchListRowAttrs = computed<Record<string, any>>(() =>
925
+ isHorizontal.value
926
+ ? {
927
+ ...labelColsAttrs(props.labelCols ?? 3),
928
+ labelClass: "text-sm-end dx-field-label-col",
929
+ }
930
+ : {},
931
+ );
897
932
 
898
- const resolvedLabel = computed(
899
- () => resolveMaybe(props.field.label) ?? props.field.key,
933
+ // The model is an array of selected option values, exactly like
934
+ // checkbox-group, so seeding/validation/diffing need nothing new. Toggling a
935
+ // row adds/removes its option's value; `setValue` also clears the field error.
936
+ const switchListValues = computed<any[]>(() =>
937
+ Array.isArray(fieldValue.value) ? fieldValue.value : [],
900
938
  );
901
939
 
902
- const resolvedHint = computed(() => resolveMaybe(props.field.hint));
940
+ function switchListIsOn(option: FieldOption): boolean {
941
+ return switchListValues.value.includes(option.value);
942
+ }
903
943
 
904
- const resolvedInfo = computed(() => resolveMaybe(props.field.info));
944
+ function setSwitchListOn(option: FieldOption, on: boolean): void {
945
+ const current = switchListValues.value;
946
+ if (on === current.includes(option.value)) return;
947
+ setValue(
948
+ on
949
+ ? [...current, option.value]
950
+ : current.filter((value) => value !== option.value),
951
+ );
952
+ }
905
953
 
906
954
  // ————————————————— switch (toggle) field
907
955
 
@@ -1101,6 +1149,29 @@ onBeforeUnmount(() => {
1101
1149
  </script>
1102
1150
 
1103
1151
  <style scoped>
1152
+ /* Switch-list rows (#160): compact rhythm with a divider between rows. The
1153
+ vertical centring of label vs switch is the `align-items-center` utility on
1154
+ each row's DFormGroup (its horizontal root IS the .row element), so no
1155
+ deep selector into bvn internals is needed here. */
1156
+ .dx-switch-list-row {
1157
+ padding: 0.25rem 0;
1158
+ border-bottom: 1px solid var(--bs-border-color);
1159
+ }
1160
+
1161
+ .dx-switch-list-row:last-of-type {
1162
+ border-bottom: 0;
1163
+ }
1164
+
1165
+ .dx-switch-list-heading {
1166
+ margin-bottom: 0.25rem;
1167
+ }
1168
+
1169
+ .dx-switch-list-control {
1170
+ display: flex;
1171
+ align-items: center;
1172
+ gap: 0.75rem;
1173
+ }
1174
+
1104
1175
  .dx-field-image-preview {
1105
1176
  max-width: 160px;
1106
1177
  max-height: 160px;
@@ -89,6 +89,7 @@ function fieldSlotMap(): Record<string, string> {
89
89
  ["info-popover", `info-popover(${key})`],
90
90
  ["hint", `hint(${key})`],
91
91
  ["repeater-row", `repeater-row(${key})`],
92
+ ["switch-list-item", `switch-list-item(${key})`],
92
93
  ];
93
94
  for (const [target, source] of candidates) {
94
95
  if (slots[source]) map[target] = source;
@@ -108,10 +108,22 @@
108
108
  @update:model-value="handleFilterChange(filterKeyFor(field), $event as string)"
109
109
  />
110
110
 
111
+ <!-- Multi-value Select Filter (#51): several values at once; the filters entry is an array -->
112
+ <DAutocomplete
113
+ v-else-if="field.filter === 'select' && field.filterMultiple"
114
+ :model-value="multiFilterValueFor(field)"
115
+ :options="getFieldFilterOptions(field)"
116
+ :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
117
+ size="sm"
118
+ multiple
119
+ open-on-focus
120
+ @update:model-value="handleMultiSelectFilterChange(field, $event)"
121
+ />
122
+
111
123
  <!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
112
124
  <DAutocomplete
113
125
  v-else-if="field.filter === 'select'"
114
- :model-value="effectiveFilters[filterKeyFor(field)] || ''"
126
+ :model-value="(effectiveFilters[filterKeyFor(field)] as string) || ''"
115
127
  :options="getFieldFilterOptions(field)"
116
128
  :placeholder="field.filterPlaceholder || filterAllLabelFor(field)"
117
129
  size="sm"
@@ -250,7 +262,7 @@
250
262
  <script setup lang="ts" generic="T = any">
251
263
  import { computed, getCurrentInstance, ref, watch } from "vue";
252
264
  import { router } from "@inertiajs/vue3";
253
- import axios from "axios";
265
+ import { api } from "../../utils/api";
254
266
  import pluralize from "pluralize";
255
267
  import { useResourceEditor } from "../../composables/useResourceEditor";
256
268
  import { getByPath } from "../../utils/objectPath";
@@ -272,6 +284,23 @@ export interface FilterOption {
272
284
  text: string;
273
285
  }
274
286
 
287
+ /**
288
+ * Adapts the built-in `apiUrl` provider's request/response to a backend whose
289
+ * convention differs from dfl's (spatie query-builder params, an envelope
290
+ * without the `{data, pagination}` shape). The sanctioned replacement for the
291
+ * axios-interceptor bridges #132 removed the route for: `request` maps the
292
+ * outgoing params (its return is what goes on the wire); `response` maps the
293
+ * body back, receiving the ORIGINAL dfl-shape params (`page`, `perPage`, …)
294
+ * so it can synthesize paginator metadata a foreign envelope lacks.
295
+ */
296
+ export interface DXTableApiAdapter {
297
+ request?: (params: Record<string, any>) => Record<string, any>;
298
+ response?: (
299
+ body: any,
300
+ context: { params: Record<string, any> },
301
+ ) => { data: any[]; pagination?: PaginationData; filterValues?: Record<string, string[]> };
302
+ }
303
+
275
304
  export interface TableField {
276
305
  key: string;
277
306
  label?: string;
@@ -286,6 +315,18 @@ export interface TableField {
286
315
  */
287
316
  filterKey?: string;
288
317
 
318
+ /**
319
+ * For a `select` filter: allow SEVERAL values at once (#51). The filter
320
+ * renders as a multi-select typeahead, the filters map holds an ARRAY of
321
+ * the chosen option values, and the query string carries Laravel bracket
322
+ * notation (`filters[status][]=active&filters[status][]=pending`).
323
+ * Client-side, a row matches when its value equals ANY chosen value. An
324
+ * empty selection means "no filter". The "All …" reset row is omitted in
325
+ * this mode (removing the chips/✕ is the way back); `filterNullText`
326
+ * stays available as a choosable value.
327
+ */
328
+ filterMultiple?: boolean;
329
+
289
330
  /**
290
331
  * For a `select` filter: adds an option meaning "has no value", e.g.
291
332
  * `filterNullText: "Unassigned"` on an assignee column. Selecting it sends
@@ -390,14 +431,19 @@ export interface Props<TItem = any> {
390
431
  /** API endpoint URL for auto-provider mode (alternative to provider function) */
391
432
  apiUrl?: string;
392
433
 
434
+ /** Adapts the built-in provider's request params / response body to a
435
+ * backend convention that differs from dfl's (see DXTableApiAdapter). */
436
+ apiAdapter?: DXTableApiAdapter;
437
+
393
438
  /** Table field definitions */
394
439
  fields: TableField[];
395
440
 
396
441
  /** Sort configuration (v-model support) */
397
442
  sortBy?: BTableSortBy[];
398
443
 
399
- /** Filter values (v-model support) - key is field key, value is filter string */
400
- filters?: Record<string, string>;
444
+ /** Filter values (v-model support) - key is field key, value is the filter
445
+ * string, or an ARRAY of values for a `filterMultiple` column (#51). */
446
+ filters?: Record<string, string | string[]>;
401
447
 
402
448
  /** Dynamic filter options from server - key is field key, value is array of values */
403
449
  filterValues?: Record<string, string[]>;
@@ -629,7 +675,7 @@ const emit = defineEmits<{
629
675
  /** Emitted (Inertia mode) when the sort column or direction changes, with the active field key and order. */
630
676
  sortChange: [sort: { key: string; order: 'asc' | 'desc' }];
631
677
  /** Emitted when the inline column filter values change (debounced for server modes, immediate for client-side). Payload is the full filter map. */
632
- filterChange: [filters: Record<string, string>];
678
+ filterChange: [filters: Record<string, string | string[]>];
633
679
  /** Emitted when the per-page selector value changes. Payload is the new page size. */
634
680
  perPageChange: [perPage: number];
635
681
  /** Emitted when a table row is clicked, with the row item, its index, and the click event. */
@@ -651,7 +697,7 @@ const emit = defineEmits<{
651
697
  /** `v-model:expandedItems` update, emitted with the rows currently expanded. */
652
698
  'update:expandedItems': [items: T[]];
653
699
  /** `v-model:filters` update, emitted with the new filter map when a column filter changes. */
654
- 'update:filters': [filters: Record<string, string>];
700
+ 'update:filters': [filters: Record<string, string | string[]>];
655
701
  /** `v-model:perPage` update, emitted with the new page size when the per-page selector changes. */
656
702
  'update:perPage': [perPage: number];
657
703
  /** `v-model:busy` update, forwarded from the underlying table's provider loading state (provider mode). */
@@ -749,7 +795,7 @@ const effectiveSortBy = computed(() =>
749
795
  );
750
796
 
751
797
  // Internal filters state, seeded from the prop when it's an initial value.
752
- const internalFilters = ref<Record<string, string>>({ ...(props.filters ?? {}) });
798
+ const internalFilters = ref<Record<string, string | string[]>>({ ...(props.filters ?? {}) });
753
799
 
754
800
  const effectiveFilters = computed(() =>
755
801
  isControlled.filters && props.filters !== undefined ? props.filters : internalFilters.value,
@@ -758,10 +804,15 @@ const effectiveFilters = computed(() =>
758
804
  // Computed: check if any field has filtering enabled
759
805
  const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
760
806
 
807
+ // Whether a filter map entry actually filters anything: a non-blank string,
808
+ // or a non-empty array for a `filterMultiple` column (#51).
809
+ const isActiveFilterValue = (value: string | string[] | undefined): boolean =>
810
+ Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value.trim() !== '';
811
+
761
812
  // Computed: check if any filters are currently active
762
813
  const hasActiveFilters = computed(() => {
763
814
  const filters = effectiveFilters.value;
764
- return Object.keys(filters).some(key => filters[key] && filters[key].trim() !== '');
815
+ return Object.keys(filters).some(key => isActiveFilterValue(filters[key]));
765
816
  });
766
817
 
767
818
  // API mode pagination metadata (extracted from responses)
@@ -825,7 +876,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
825
876
  if (!isClientSideMode.value || !props.items) return [];
826
877
 
827
878
  const filters = effectiveFilters.value;
828
- const filterKeys = Object.keys(filters).filter(key => filters[key] && filters[key].trim() !== '');
879
+ const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
829
880
 
830
881
  if (filterKeys.length === 0) {
831
882
  return props.items;
@@ -833,7 +884,6 @@ const clientSideFilteredItems = computed<T[]>(() => {
833
884
 
834
885
  return props.items.filter(item => {
835
886
  return filterKeys.every(key => {
836
- const filterValue = filters[key].trim().toLowerCase();
837
887
  // Resolve by the FILTER key, which may differ from the column's key.
838
888
  const field = props.fields.find(f => filterKeyFor(f) === key);
839
889
  // `filterKey` names the param the SERVER filters on; a local row may
@@ -847,39 +897,47 @@ const clientSideFilteredItems = computed<T[]>(() => {
847
897
  ? fieldValueOf(item, key)
848
898
  : fieldValueOf(item, field?.key ?? key);
849
899
 
850
- // The "no value" option (e.g. Unassigned) is the one filter that
851
- // MATCHES an absent value rather than failing on it.
852
- if (field?.filterNullText && filters[key] === filterNullValueFor(field)) {
853
- return itemValue === null || itemValue === undefined || itemValue === '';
854
- }
900
+ const matchesCandidate = (candidate: string): boolean => {
901
+ // The "no value" option (e.g. Unassigned) is the one filter that
902
+ // MATCHES an absent value rather than failing on it.
903
+ if (field?.filterNullText && candidate === filterNullValueFor(field)) {
904
+ return itemValue === null || itemValue === undefined || itemValue === '';
905
+ }
855
906
 
856
- if (itemValue === null || itemValue === undefined) {
857
- return false;
858
- }
907
+ if (itemValue === null || itemValue === undefined) {
908
+ return false;
909
+ }
859
910
 
860
- const filterType = field?.filter;
911
+ const filterValue = candidate.trim().toLowerCase();
861
912
 
862
- switch (filterType) {
863
- case 'text':
864
- // Case-insensitive contains search
865
- return String(itemValue).toLowerCase().includes(filterValue);
913
+ switch (field?.filter) {
914
+ case 'text':
915
+ // Case-insensitive contains search
916
+ return String(itemValue).toLowerCase().includes(filterValue);
866
917
 
867
- case 'select':
868
- // Exact match
869
- return String(itemValue) === filters[key];
918
+ case 'select':
919
+ // Exact match
920
+ return String(itemValue) === candidate;
870
921
 
871
- case 'number':
872
- // Exact numeric match
873
- return Number(itemValue) === Number(filters[key]);
922
+ case 'number':
923
+ // Exact numeric match
924
+ return Number(itemValue) === Number(candidate);
874
925
 
875
- case 'date':
876
- // Exact date match
877
- return String(itemValue) === filters[key];
926
+ case 'date':
927
+ // Exact date match
928
+ return String(itemValue) === candidate;
878
929
 
879
- default:
880
- // Default: case-insensitive contains
881
- return String(itemValue).toLowerCase().includes(filterValue);
882
- }
930
+ default:
931
+ // Default: case-insensitive contains
932
+ return String(itemValue).toLowerCase().includes(filterValue);
933
+ }
934
+ };
935
+
936
+ // A `filterMultiple` column holds an ARRAY of values; the row
937
+ // matches when it matches ANY of them (#51). A scalar filter is
938
+ // the one-candidate case of the same rule.
939
+ const raw = filters[key];
940
+ return (Array.isArray(raw) ? raw : [raw]).some(matchesCandidate);
883
941
  });
884
942
  });
885
943
  });
@@ -1019,6 +1077,19 @@ const handleSelectFilterChange = (field: TableField, value: unknown) => {
1019
1077
  handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
1020
1078
  };
1021
1079
 
1080
+ // Multi-value select filter (#51): the autocomplete's array model maps
1081
+ // straight onto the filters entry; an emptied selection removes the entry.
1082
+ const multiFilterValueFor = (field: TableField): string[] => {
1083
+ const value = effectiveFilters.value[filterKeyFor(field)];
1084
+ if (Array.isArray(value)) return value;
1085
+ return isActiveFilterValue(value) ? [String(value)] : [];
1086
+ };
1087
+
1088
+ const handleMultiSelectFilterChange = (field: TableField, value: unknown) => {
1089
+ const selected = (Array.isArray(value) ? value : []).map(String);
1090
+ handleFilterChange(filterKeyFor(field), selected);
1091
+ };
1092
+
1022
1093
  const filterAllLabelFor = (field: TableField): string =>
1023
1094
  field.filterAllText ?? field.filterPlaceholder ?? `All ${field.label || field.key}`;
1024
1095
 
@@ -1042,7 +1113,13 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1042
1113
  // so the obvious encoding of "no filter" silently empties the dropdown.
1043
1114
  // `handleSelectFilterChange` translates it back to `''`, which is already
1044
1115
  // how the filter map represents unset.
1045
- options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1116
+ //
1117
+ // A `filterMultiple` column omits it (#51): "no filter" inside a
1118
+ // multi-select reads as a selectable value, and clearing is already
1119
+ // discoverable there — remove the chips, or the ✕.
1120
+ if (!field.filterMultiple) {
1121
+ options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1122
+ }
1046
1123
 
1047
1124
  // An "Unassigned"-style option, so a select filter can express "has no value"
1048
1125
  // — a different thing from "no filter" above.
@@ -1136,16 +1213,19 @@ watch(() => props.pagination?.per_page, (newPerPage) => {
1136
1213
  }
1137
1214
  });
1138
1215
 
1139
- // Watch for external filter changes (when filters prop is controlled by parent)
1140
- watch(() => props.filters, (newFilters, oldFilters) => {
1216
+ // Watch for external filter changes (when filters prop is controlled by parent).
1217
+ // Watches the SERIALISED map, not the object: a deep watch hands the same
1218
+ // object reference as both new and old value when a consumer mutates in place
1219
+ // (`filters.status.push(...)` on a multi-value array), so an old==new JSON
1220
+ // guard ate exactly the changes it was meant to detect. A string source only
1221
+ // fires when the content genuinely changed, and stringifying inside the getter
1222
+ // makes every nested key a reactive dependency.
1223
+ watch(() => JSON.stringify(props.filters ?? null), () => {
1141
1224
  // Only when the consumer CONTROLS filters. Uncontrolled, the prop is just a
1142
1225
  // seed: `effectiveFilters` stays internal, so refreshing on a later prop
1143
1226
  // change would fetch with filters the table isn't actually showing (#110).
1144
1227
  if (!isControlled.filters || props.filters === undefined) return;
1145
1228
 
1146
- // Only refresh if filters actually changed
1147
- if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
1148
-
1149
1229
  // Refresh data for the new filters
1150
1230
  if (isProviderMode.value) {
1151
1231
  refresh();
@@ -1155,14 +1235,14 @@ watch(() => props.filters, (newFilters, oldFilters) => {
1155
1235
  props.inertiaUrl!,
1156
1236
  {
1157
1237
  page: 1, // Reset to first page when filters change
1158
- filters: newFilters,
1238
+ filters: props.filters,
1159
1239
  perPage: effectivePerPage.value,
1160
1240
  ...sortParams(effectiveSortBy.value),
1161
1241
  },
1162
1242
  { preserveState: true }
1163
1243
  );
1164
1244
  }
1165
- }, { deep: true });
1245
+ });
1166
1246
 
1167
1247
  // Watch apiUrl changes to reset filter cache and refetch. The api-url isn't part
1168
1248
  // of BTable's provider context, so an api-url-only change doesn't re-invoke the
@@ -1360,19 +1440,41 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1360
1440
  params.filterValues = fieldsNeedingFilterValues.value;
1361
1441
  }
1362
1442
 
1363
- const response = await axios.get(props.apiUrl, { params });
1443
+ // Adapt the outgoing params to the backend's convention (e.g. spatie
1444
+ // query-builder's `sort=-name` / `filter[key]`). This is the sanctioned
1445
+ // replacement for the axios request interceptors #132 removed the route
1446
+ // for — the adapter's return IS the wire params.
1447
+ const wireParams = props.apiAdapter?.request
1448
+ ? props.apiAdapter.request(params)
1449
+ : params;
1450
+
1451
+ const response = await api.get<any>(props.apiUrl, wireParams);
1452
+
1453
+ // Adapt a foreign response envelope back into the dfl shape. Gets the
1454
+ // ORIGINAL dfl-shape params (page/perPage/…) for context, since a foreign
1455
+ // envelope often lacks paginator metadata the adapter must synthesize.
1456
+ let body = props.apiAdapter?.response
1457
+ ? props.apiAdapter.response(response.data, { params })
1458
+ : response.data;
1459
+
1460
+ // A backend that ignores the pagination convention and returns a bare
1461
+ // array of rows used to render as a SILENTLY EMPTY table (data present,
1462
+ // nothing shown). Degrade to visible rows with no pager metadata instead.
1463
+ if (Array.isArray(body)) {
1464
+ body = { data: body };
1465
+ }
1364
1466
 
1365
1467
  // Extract and store pagination metadata for display
1366
- if (response.data.pagination) {
1367
- apiPaginationMeta.value = response.data.pagination;
1468
+ if (body.pagination) {
1469
+ apiPaginationMeta.value = body.pagination;
1368
1470
  }
1369
1471
 
1370
1472
  // Extract and store filter values
1371
- if (response.data.filterValues) {
1372
- apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
1473
+ if (body.filterValues) {
1474
+ apiFilterValues.value = { ...apiFilterValues.value, ...body.filterValues };
1373
1475
  }
1374
1476
 
1375
- return response.data.data;
1477
+ return body.data;
1376
1478
  };
1377
1479
 
1378
1480
  /**
@@ -1393,6 +1495,8 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
1393
1495
  } catch (error: any) {
1394
1496
  // eslint-disable-next-line no-console
1395
1497
  console.error('DXTable: the data provider failed', error);
1498
+ // `error.message` covers the internal provider's ApiError; the
1499
+ // `response.data` branch covers a consumer's own axios-based provider.
1396
1500
  apiError.value =
1397
1501
  error?.response?.data?.message ||
1398
1502
  error?.message ||
@@ -1600,12 +1704,12 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1600
1704
  emit('perPageChange', perPageNum);
1601
1705
  };
1602
1706
 
1603
- const handleFilterChange = (fieldKey: string, value: string) => {
1707
+ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
1604
1708
  // Update filters
1605
1709
  const newFilters = { ...effectiveFilters.value, [fieldKey]: value };
1606
1710
 
1607
- // Remove empty filters
1608
- if (!value || value.trim() === '') {
1711
+ // Remove empty filters (a blank string, or an empty multi-value array)
1712
+ if (!isActiveFilterValue(value)) {
1609
1713
  delete newFilters[fieldKey];
1610
1714
  }
1611
1715
 
@@ -1859,4 +1963,11 @@ defineExpose({
1859
1963
  :deep(tbody tr.dx-row-actionable:hover) {
1860
1964
  background-color: var(--bs-table-hover-bg);
1861
1965
  }
1966
+
1967
+ /* Muted header titles (#157): in a data table the CONTENT is what matters —
1968
+ near-black bold headers compete with it. Grey (still bold) keeps the
1969
+ structure without the shout. Consumers re-louden via the token. */
1970
+ :deep(thead th) {
1971
+ color: var(--dx-table-header-color, var(--bs-secondary-color));
1972
+ }
1862
1973
  </style>