@omnitend/dashboard-for-laravel 0.31.0 → 0.33.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.
@@ -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";
@@ -286,6 +298,18 @@ export interface TableField {
286
298
  */
287
299
  filterKey?: string;
288
300
 
301
+ /**
302
+ * For a `select` filter: allow SEVERAL values at once (#51). The filter
303
+ * renders as a multi-select typeahead, the filters map holds an ARRAY of
304
+ * the chosen option values, and the query string carries Laravel bracket
305
+ * notation (`filters[status][]=active&filters[status][]=pending`).
306
+ * Client-side, a row matches when its value equals ANY chosen value. An
307
+ * empty selection means "no filter". The "All …" reset row is omitted in
308
+ * this mode (removing the chips/✕ is the way back); `filterNullText`
309
+ * stays available as a choosable value.
310
+ */
311
+ filterMultiple?: boolean;
312
+
289
313
  /**
290
314
  * For a `select` filter: adds an option meaning "has no value", e.g.
291
315
  * `filterNullText: "Unassigned"` on an assignee column. Selecting it sends
@@ -396,8 +420,9 @@ export interface Props<TItem = any> {
396
420
  /** Sort configuration (v-model support) */
397
421
  sortBy?: BTableSortBy[];
398
422
 
399
- /** Filter values (v-model support) - key is field key, value is filter string */
400
- filters?: Record<string, string>;
423
+ /** Filter values (v-model support) - key is field key, value is the filter
424
+ * string, or an ARRAY of values for a `filterMultiple` column (#51). */
425
+ filters?: Record<string, string | string[]>;
401
426
 
402
427
  /** Dynamic filter options from server - key is field key, value is array of values */
403
428
  filterValues?: Record<string, string[]>;
@@ -629,7 +654,7 @@ const emit = defineEmits<{
629
654
  /** Emitted (Inertia mode) when the sort column or direction changes, with the active field key and order. */
630
655
  sortChange: [sort: { key: string; order: 'asc' | 'desc' }];
631
656
  /** 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>];
657
+ filterChange: [filters: Record<string, string | string[]>];
633
658
  /** Emitted when the per-page selector value changes. Payload is the new page size. */
634
659
  perPageChange: [perPage: number];
635
660
  /** Emitted when a table row is clicked, with the row item, its index, and the click event. */
@@ -651,7 +676,7 @@ const emit = defineEmits<{
651
676
  /** `v-model:expandedItems` update, emitted with the rows currently expanded. */
652
677
  'update:expandedItems': [items: T[]];
653
678
  /** `v-model:filters` update, emitted with the new filter map when a column filter changes. */
654
- 'update:filters': [filters: Record<string, string>];
679
+ 'update:filters': [filters: Record<string, string | string[]>];
655
680
  /** `v-model:perPage` update, emitted with the new page size when the per-page selector changes. */
656
681
  'update:perPage': [perPage: number];
657
682
  /** `v-model:busy` update, forwarded from the underlying table's provider loading state (provider mode). */
@@ -749,7 +774,7 @@ const effectiveSortBy = computed(() =>
749
774
  );
750
775
 
751
776
  // Internal filters state, seeded from the prop when it's an initial value.
752
- const internalFilters = ref<Record<string, string>>({ ...(props.filters ?? {}) });
777
+ const internalFilters = ref<Record<string, string | string[]>>({ ...(props.filters ?? {}) });
753
778
 
754
779
  const effectiveFilters = computed(() =>
755
780
  isControlled.filters && props.filters !== undefined ? props.filters : internalFilters.value,
@@ -758,10 +783,15 @@ const effectiveFilters = computed(() =>
758
783
  // Computed: check if any field has filtering enabled
759
784
  const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
760
785
 
786
+ // Whether a filter map entry actually filters anything: a non-blank string,
787
+ // or a non-empty array for a `filterMultiple` column (#51).
788
+ const isActiveFilterValue = (value: string | string[] | undefined): boolean =>
789
+ Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value.trim() !== '';
790
+
761
791
  // Computed: check if any filters are currently active
762
792
  const hasActiveFilters = computed(() => {
763
793
  const filters = effectiveFilters.value;
764
- return Object.keys(filters).some(key => filters[key] && filters[key].trim() !== '');
794
+ return Object.keys(filters).some(key => isActiveFilterValue(filters[key]));
765
795
  });
766
796
 
767
797
  // API mode pagination metadata (extracted from responses)
@@ -825,7 +855,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
825
855
  if (!isClientSideMode.value || !props.items) return [];
826
856
 
827
857
  const filters = effectiveFilters.value;
828
- const filterKeys = Object.keys(filters).filter(key => filters[key] && filters[key].trim() !== '');
858
+ const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
829
859
 
830
860
  if (filterKeys.length === 0) {
831
861
  return props.items;
@@ -833,7 +863,6 @@ const clientSideFilteredItems = computed<T[]>(() => {
833
863
 
834
864
  return props.items.filter(item => {
835
865
  return filterKeys.every(key => {
836
- const filterValue = filters[key].trim().toLowerCase();
837
866
  // Resolve by the FILTER key, which may differ from the column's key.
838
867
  const field = props.fields.find(f => filterKeyFor(f) === key);
839
868
  // `filterKey` names the param the SERVER filters on; a local row may
@@ -847,39 +876,47 @@ const clientSideFilteredItems = computed<T[]>(() => {
847
876
  ? fieldValueOf(item, key)
848
877
  : fieldValueOf(item, field?.key ?? key);
849
878
 
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
- }
879
+ const matchesCandidate = (candidate: string): boolean => {
880
+ // The "no value" option (e.g. Unassigned) is the one filter that
881
+ // MATCHES an absent value rather than failing on it.
882
+ if (field?.filterNullText && candidate === filterNullValueFor(field)) {
883
+ return itemValue === null || itemValue === undefined || itemValue === '';
884
+ }
855
885
 
856
- if (itemValue === null || itemValue === undefined) {
857
- return false;
858
- }
886
+ if (itemValue === null || itemValue === undefined) {
887
+ return false;
888
+ }
859
889
 
860
- const filterType = field?.filter;
890
+ const filterValue = candidate.trim().toLowerCase();
861
891
 
862
- switch (filterType) {
863
- case 'text':
864
- // Case-insensitive contains search
865
- return String(itemValue).toLowerCase().includes(filterValue);
892
+ switch (field?.filter) {
893
+ case 'text':
894
+ // Case-insensitive contains search
895
+ return String(itemValue).toLowerCase().includes(filterValue);
866
896
 
867
- case 'select':
868
- // Exact match
869
- return String(itemValue) === filters[key];
897
+ case 'select':
898
+ // Exact match
899
+ return String(itemValue) === candidate;
870
900
 
871
- case 'number':
872
- // Exact numeric match
873
- return Number(itemValue) === Number(filters[key]);
901
+ case 'number':
902
+ // Exact numeric match
903
+ return Number(itemValue) === Number(candidate);
874
904
 
875
- case 'date':
876
- // Exact date match
877
- return String(itemValue) === filters[key];
905
+ case 'date':
906
+ // Exact date match
907
+ return String(itemValue) === candidate;
878
908
 
879
- default:
880
- // Default: case-insensitive contains
881
- return String(itemValue).toLowerCase().includes(filterValue);
882
- }
909
+ default:
910
+ // Default: case-insensitive contains
911
+ return String(itemValue).toLowerCase().includes(filterValue);
912
+ }
913
+ };
914
+
915
+ // A `filterMultiple` column holds an ARRAY of values; the row
916
+ // matches when it matches ANY of them (#51). A scalar filter is
917
+ // the one-candidate case of the same rule.
918
+ const raw = filters[key];
919
+ return (Array.isArray(raw) ? raw : [raw]).some(matchesCandidate);
883
920
  });
884
921
  });
885
922
  });
@@ -1019,6 +1056,19 @@ const handleSelectFilterChange = (field: TableField, value: unknown) => {
1019
1056
  handleFilterChange(filterKeyFor(field), selected === FILTER_ALL_VALUE ? '' : selected);
1020
1057
  };
1021
1058
 
1059
+ // Multi-value select filter (#51): the autocomplete's array model maps
1060
+ // straight onto the filters entry; an emptied selection removes the entry.
1061
+ const multiFilterValueFor = (field: TableField): string[] => {
1062
+ const value = effectiveFilters.value[filterKeyFor(field)];
1063
+ if (Array.isArray(value)) return value;
1064
+ return isActiveFilterValue(value) ? [String(value)] : [];
1065
+ };
1066
+
1067
+ const handleMultiSelectFilterChange = (field: TableField, value: unknown) => {
1068
+ const selected = (Array.isArray(value) ? value : []).map(String);
1069
+ handleFilterChange(filterKeyFor(field), selected);
1070
+ };
1071
+
1022
1072
  const filterAllLabelFor = (field: TableField): string =>
1023
1073
  field.filterAllText ?? field.filterPlaceholder ?? `All ${field.label || field.key}`;
1024
1074
 
@@ -1042,7 +1092,13 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1042
1092
  // so the obvious encoding of "no filter" silently empties the dropdown.
1043
1093
  // `handleSelectFilterChange` translates it back to `''`, which is already
1044
1094
  // how the filter map represents unset.
1045
- options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1095
+ //
1096
+ // A `filterMultiple` column omits it (#51): "no filter" inside a
1097
+ // multi-select reads as a selectable value, and clearing is already
1098
+ // discoverable there — remove the chips, or the ✕.
1099
+ if (!field.filterMultiple) {
1100
+ options.push({ value: FILTER_ALL_VALUE, text: filterAllLabelFor(field) });
1101
+ }
1046
1102
 
1047
1103
  // An "Unassigned"-style option, so a select filter can express "has no value"
1048
1104
  // — a different thing from "no filter" above.
@@ -1136,16 +1192,19 @@ watch(() => props.pagination?.per_page, (newPerPage) => {
1136
1192
  }
1137
1193
  });
1138
1194
 
1139
- // Watch for external filter changes (when filters prop is controlled by parent)
1140
- watch(() => props.filters, (newFilters, oldFilters) => {
1195
+ // Watch for external filter changes (when filters prop is controlled by parent).
1196
+ // Watches the SERIALISED map, not the object: a deep watch hands the same
1197
+ // object reference as both new and old value when a consumer mutates in place
1198
+ // (`filters.status.push(...)` on a multi-value array), so an old==new JSON
1199
+ // guard ate exactly the changes it was meant to detect. A string source only
1200
+ // fires when the content genuinely changed, and stringifying inside the getter
1201
+ // makes every nested key a reactive dependency.
1202
+ watch(() => JSON.stringify(props.filters ?? null), () => {
1141
1203
  // Only when the consumer CONTROLS filters. Uncontrolled, the prop is just a
1142
1204
  // seed: `effectiveFilters` stays internal, so refreshing on a later prop
1143
1205
  // change would fetch with filters the table isn't actually showing (#110).
1144
1206
  if (!isControlled.filters || props.filters === undefined) return;
1145
1207
 
1146
- // Only refresh if filters actually changed
1147
- if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
1148
-
1149
1208
  // Refresh data for the new filters
1150
1209
  if (isProviderMode.value) {
1151
1210
  refresh();
@@ -1155,14 +1214,14 @@ watch(() => props.filters, (newFilters, oldFilters) => {
1155
1214
  props.inertiaUrl!,
1156
1215
  {
1157
1216
  page: 1, // Reset to first page when filters change
1158
- filters: newFilters,
1217
+ filters: props.filters,
1159
1218
  perPage: effectivePerPage.value,
1160
1219
  ...sortParams(effectiveSortBy.value),
1161
1220
  },
1162
1221
  { preserveState: true }
1163
1222
  );
1164
1223
  }
1165
- }, { deep: true });
1224
+ });
1166
1225
 
1167
1226
  // Watch apiUrl changes to reset filter cache and refetch. The api-url isn't part
1168
1227
  // of BTable's provider context, so an api-url-only change doesn't re-invoke the
@@ -1360,7 +1419,7 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
1360
1419
  params.filterValues = fieldsNeedingFilterValues.value;
1361
1420
  }
1362
1421
 
1363
- const response = await axios.get(props.apiUrl, { params });
1422
+ const response = await api.get<any>(props.apiUrl, params);
1364
1423
 
1365
1424
  // Extract and store pagination metadata for display
1366
1425
  if (response.data.pagination) {
@@ -1393,6 +1452,8 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
1393
1452
  } catch (error: any) {
1394
1453
  // eslint-disable-next-line no-console
1395
1454
  console.error('DXTable: the data provider failed', error);
1455
+ // `error.message` covers the internal provider's ApiError; the
1456
+ // `response.data` branch covers a consumer's own axios-based provider.
1396
1457
  apiError.value =
1397
1458
  error?.response?.data?.message ||
1398
1459
  error?.message ||
@@ -1600,12 +1661,12 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
1600
1661
  emit('perPageChange', perPageNum);
1601
1662
  };
1602
1663
 
1603
- const handleFilterChange = (fieldKey: string, value: string) => {
1664
+ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
1604
1665
  // Update filters
1605
1666
  const newFilters = { ...effectiveFilters.value, [fieldKey]: value };
1606
1667
 
1607
- // Remove empty filters
1608
- if (!value || value.trim() === '') {
1668
+ // Remove empty filters (a blank string, or an empty multi-value array)
1669
+ if (!isActiveFilterValue(value)) {
1609
1670
  delete newFilters[fieldKey];
1610
1671
  }
1611
1672
 
@@ -1859,4 +1920,11 @@ defineExpose({
1859
1920
  :deep(tbody tr.dx-row-actionable:hover) {
1860
1921
  background-color: var(--bs-table-hover-bg);
1861
1922
  }
1923
+
1924
+ /* Muted header titles (#157): in a data table the CONTENT is what matters —
1925
+ near-black bold headers compete with it. Grey (still bold) keeps the
1926
+ structure without the shout. Consumers re-louden via the token. */
1927
+ :deep(thead th) {
1928
+ color: var(--dx-table-header-color, var(--bs-secondary-color));
1929
+ }
1862
1930
  </style>