@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.
- package/dist/components/extended/DXCurrencyInput.vue.d.ts +27 -0
- package/dist/components/extended/DXField.vue.d.ts +30 -15
- package/dist/components/extended/DXTable.vue.d.ts +39 -5
- package/dist/composables/defineForm.d.ts +8 -2
- package/dist/dashboard-for-laravel.js +8503 -8351
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +21 -9
- package/docs/public/api-reference.json +85 -5
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -5
- package/resources/css/theme.scss +34 -0
- package/resources/js/components/extended/DXCurrencyInput.vue +143 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +21 -16
- package/resources/js/components/extended/DXField.vue +142 -71
- package/resources/js/components/extended/DXFormField.vue +1 -0
- package/resources/js/components/extended/DXTable.vue +164 -53
- package/resources/js/composables/defineForm.ts +12 -2
- package/resources/js/composables/useResourceEditor.ts +3 -5
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +22 -8
- package/resources/js/utils/api.ts +13 -5
|
@@ -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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
873
|
-
() =>
|
|
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
|
-
|
|
882
|
-
isCurrencyFocused.value = true;
|
|
883
|
-
}
|
|
914
|
+
const resolvedHint = computed(() => resolveMaybe(props.field.hint));
|
|
884
915
|
|
|
885
|
-
|
|
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
|
-
|
|
892
|
-
isCurrencyFocused.value = false;
|
|
893
|
-
currencyDisplayValue.value = formatCurrency(fieldValue.value);
|
|
894
|
-
}
|
|
918
|
+
// ————————————————— switch-list (#160)
|
|
895
919
|
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
940
|
+
function switchListIsOn(option: FieldOption): boolean {
|
|
941
|
+
return switchListValues.value.includes(option.value);
|
|
942
|
+
}
|
|
903
943
|
|
|
904
|
-
|
|
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
|
|
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
|
|
400
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
907
|
+
if (itemValue === null || itemValue === undefined) {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
859
910
|
|
|
860
|
-
|
|
911
|
+
const filterValue = candidate.trim().toLowerCase();
|
|
861
912
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
913
|
+
switch (field?.filter) {
|
|
914
|
+
case 'text':
|
|
915
|
+
// Case-insensitive contains search
|
|
916
|
+
return String(itemValue).toLowerCase().includes(filterValue);
|
|
866
917
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
918
|
+
case 'select':
|
|
919
|
+
// Exact match
|
|
920
|
+
return String(itemValue) === candidate;
|
|
870
921
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
922
|
+
case 'number':
|
|
923
|
+
// Exact numeric match
|
|
924
|
+
return Number(itemValue) === Number(candidate);
|
|
874
925
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
926
|
+
case 'date':
|
|
927
|
+
// Exact date match
|
|
928
|
+
return String(itemValue) === candidate;
|
|
878
929
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
}
|
|
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
|
-
|
|
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 (
|
|
1367
|
-
apiPaginationMeta.value =
|
|
1468
|
+
if (body.pagination) {
|
|
1469
|
+
apiPaginationMeta.value = body.pagination;
|
|
1368
1470
|
}
|
|
1369
1471
|
|
|
1370
1472
|
// Extract and store filter values
|
|
1371
|
-
if (
|
|
1372
|
-
apiFilterValues.value = { ...apiFilterValues.value, ...
|
|
1473
|
+
if (body.filterValues) {
|
|
1474
|
+
apiFilterValues.value = { ...apiFilterValues.value, ...body.filterValues };
|
|
1373
1475
|
}
|
|
1374
1476
|
|
|
1375
|
-
return
|
|
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
|
|
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>
|