@omnitend/dashboard-for-laravel 0.32.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.
- 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 +17 -5
- package/dist/composables/defineForm.d.ts +8 -2
- package/dist/dashboard-for-laravel.js +8489 -8340
- 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 +79 -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 +116 -48
- 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";
|
|
@@ -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
|
|
400
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
886
|
+
if (itemValue === null || itemValue === undefined) {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
859
889
|
|
|
860
|
-
|
|
890
|
+
const filterValue = candidate.trim().toLowerCase();
|
|
861
891
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
892
|
+
switch (field?.filter) {
|
|
893
|
+
case 'text':
|
|
894
|
+
// Case-insensitive contains search
|
|
895
|
+
return String(itemValue).toLowerCase().includes(filterValue);
|
|
866
896
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
897
|
+
case 'select':
|
|
898
|
+
// Exact match
|
|
899
|
+
return String(itemValue) === candidate;
|
|
870
900
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
901
|
+
case 'number':
|
|
902
|
+
// Exact numeric match
|
|
903
|
+
return Number(itemValue) === Number(candidate);
|
|
874
904
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
905
|
+
case 'date':
|
|
906
|
+
// Exact date match
|
|
907
|
+
return String(itemValue) === candidate;
|
|
878
908
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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>
|
|
@@ -5,8 +5,14 @@ import type { FieldDefinition, FieldType } from "../types";
|
|
|
5
5
|
* Extended field definition that includes default value
|
|
6
6
|
*/
|
|
7
7
|
export interface FormFieldDefinition<TValue = any> extends FieldDefinition {
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Default/initial value for the field. Optional: when omitted, the field
|
|
10
|
+
* seeds from its type (`false` for checkbox/switch, `[]` for
|
|
11
|
+
* checkbox-group/switch-list/repeater, `0` for numerics, `""` otherwise) —
|
|
12
|
+
* previously the type required this even though the runtime had a
|
|
13
|
+
* fallback, so typed consumers could never use it.
|
|
14
|
+
*/
|
|
15
|
+
default?: TValue;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/**
|
|
@@ -37,6 +43,10 @@ function getDefaultValueForType(type: FieldType): any {
|
|
|
37
43
|
case "percentage":
|
|
38
44
|
return 0;
|
|
39
45
|
case "repeater":
|
|
46
|
+
// Array-valued selections: checkbox-group previously fell through to
|
|
47
|
+
// the "" default, which is the wrong shape for an array model.
|
|
48
|
+
case "checkbox-group":
|
|
49
|
+
case "switch-list":
|
|
40
50
|
return [];
|
|
41
51
|
case "image":
|
|
42
52
|
case "file":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, ref, type Ref } from "vue";
|
|
2
|
-
import
|
|
2
|
+
import { api } from "../utils/api";
|
|
3
3
|
import { useForm } from "./useForm";
|
|
4
4
|
import { useToast } from "./useToast";
|
|
5
5
|
|
|
@@ -254,7 +254,7 @@ export function useResourceEditor<T = any>(
|
|
|
254
254
|
editLoading.value = true;
|
|
255
255
|
try {
|
|
256
256
|
const url = props.showUrl!.replace(':id', String(itemId));
|
|
257
|
-
const response = await
|
|
257
|
+
const response = await api.get<any>(url);
|
|
258
258
|
// Superseded by a newer row-open — discard.
|
|
259
259
|
if (token !== editFetchToken) return;
|
|
260
260
|
|
|
@@ -271,9 +271,7 @@ export function useResourceEditor<T = any>(
|
|
|
271
271
|
} catch (error: any) {
|
|
272
272
|
if (token !== editFetchToken) return;
|
|
273
273
|
const message =
|
|
274
|
-
error?.
|
|
275
|
-
error?.message ??
|
|
276
|
-
'Failed to load the full record.';
|
|
274
|
+
error?.message ?? 'Failed to load the full record.';
|
|
277
275
|
createToast?.({
|
|
278
276
|
title: 'Error',
|
|
279
277
|
body: message,
|
package/resources/js/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { default as DXForm } from "./components/extended/DXForm.vue";
|
|
|
10
10
|
export { default as DXField } from "./components/extended/DXField.vue";
|
|
11
11
|
export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
|
|
12
12
|
export { default as DXSwitch } from "./components/extended/DXSwitch.vue";
|
|
13
|
+
export { default as DXCurrencyInput } from "./components/extended/DXCurrencyInput.vue";
|
|
13
14
|
export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
|
|
14
15
|
// Chart components moved to the `@omnitend/dashboard-for-laravel/charts` entry
|
|
15
16
|
// (#142) so chart.js / vue-chartjs stay genuinely optional peers — the main
|