@omnitend/dashboard-for-laravel 0.34.0 → 0.36.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/charts.js +137 -97
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/charts/chartTheme.d.ts +29 -3
- package/dist/components/charts/useThemedChart.d.ts +7 -2
- package/dist/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXTable.vue.d.ts +38 -1
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +7 -2
- package/dist/components/extended/DXTablePagination.vue.d.ts +22 -1
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/dashboard-for-laravel.js +8288 -8105
- 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 +2 -0
- package/dist/style.css +1 -1
- package/docs/public/api-reference.json +48 -8
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/css/theme.scss +96 -4
- package/resources/js/components/charts/DXBarChart.vue +7 -2
- package/resources/js/components/charts/DXDoughnutChart.vue +33 -10
- package/resources/js/components/charts/DXLineChart.vue +7 -2
- package/resources/js/components/charts/chartTheme.ts +106 -11
- package/resources/js/components/charts/useThemedChart.ts +39 -21
- package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXForm.vue +83 -9
- package/resources/js/components/extended/DXTable.vue +216 -12
- package/resources/js/components/extended/DXTableEditorModal.vue +7 -2
- package/resources/js/components/extended/DXTablePagination.vue +165 -8
- package/resources/js/components/extended/DXTableShell.vue +105 -1
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/index.ts +7 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { computed, type ComputedRef } from "vue";
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from "vue";
|
|
2
2
|
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
|
|
3
3
|
import {
|
|
4
4
|
baseOptions,
|
|
5
5
|
applyPalette,
|
|
6
6
|
mergeOptions,
|
|
7
|
+
useColorModeVersion,
|
|
7
8
|
type ChartValueFormat,
|
|
8
9
|
} from "./chartTheme";
|
|
9
10
|
|
|
@@ -35,35 +36,52 @@ export interface ThemedChartProps<TType extends "bar" | "line"> {
|
|
|
35
36
|
* data-viz palette to un-coloured datasets and deep-merges the consumer's
|
|
36
37
|
* `options` over the dashboard-friendly defaults. Pass the reactive `props`
|
|
37
38
|
* object (property access stays reactive inside the computeds).
|
|
39
|
+
*
|
|
40
|
+
* `host` is the chart's own container element (a template ref). It is BOTH the
|
|
41
|
+
* `data-bs-theme` scope the palette resolves against and — being a ref read
|
|
42
|
+
* inside the computeds — the trigger that re-themes once it is populated on
|
|
43
|
+
* mount, since the first render happens before a template ref exists (#161).
|
|
38
44
|
*/
|
|
39
45
|
export function useThemedChart<TType extends "bar" | "line">(
|
|
40
46
|
type: TType,
|
|
41
47
|
props: ThemedChartProps<TType>,
|
|
48
|
+
host: Ref<HTMLElement | null>,
|
|
42
49
|
): {
|
|
43
50
|
chartData: ComputedRef<ChartData<TType>>;
|
|
44
51
|
mergedOptions: ComputedRef<ChartOptions<TType>>;
|
|
45
52
|
} {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
datasets: applyPalette(props.datasets, type, props.labels.length),
|
|
51
|
-
}) as ChartData<TType>,
|
|
52
|
-
);
|
|
53
|
+
// Bumped by a MutationObserver on every `data-bs-theme` change; read below
|
|
54
|
+
// so the CSS-variable palette — invisible to Vue's reactivity — is
|
|
55
|
+
// re-resolved when the colour mode flips under a mounted chart.
|
|
56
|
+
const colorModeVersion = useColorModeVersion();
|
|
53
57
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
const chartData = computed(() => {
|
|
59
|
+
void colorModeVersion.value;
|
|
60
|
+
return {
|
|
61
|
+
labels: props.labels,
|
|
62
|
+
datasets: applyPalette(
|
|
63
|
+
props.datasets,
|
|
64
|
+
type,
|
|
65
|
+
props.labels.length,
|
|
66
|
+
host.value,
|
|
67
|
+
),
|
|
68
|
+
} as ChartData<TType>;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const mergedOptions = computed(() => {
|
|
72
|
+
void colorModeVersion.value;
|
|
73
|
+
return mergeOptions(
|
|
74
|
+
baseOptions({
|
|
75
|
+
valueFormat: props.valueFormat,
|
|
76
|
+
currencySymbol: props.currencySymbol,
|
|
77
|
+
locale: props.locale,
|
|
78
|
+
showLegend: props.showLegend ?? props.datasets.length > 1,
|
|
79
|
+
hasValueAxis: true,
|
|
80
|
+
scope: host.value,
|
|
81
|
+
}),
|
|
82
|
+
props.options ?? {},
|
|
83
|
+
) as ChartOptions<TType>;
|
|
84
|
+
});
|
|
67
85
|
|
|
68
86
|
return { chartData, mergedOptions };
|
|
69
87
|
}
|
|
@@ -426,7 +426,7 @@ watch(activeGroupIndex, () => {
|
|
|
426
426
|
overflow-x: hidden;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
/* Dense rhythm (#95):
|
|
429
|
+
/* Dense rhythm (#95): values proven in a production dashboard, adopted as the default —
|
|
430
430
|
natural-case headers at 0.875rem (no uppercase/letter-spacing), tight group
|
|
431
431
|
spacing. */
|
|
432
432
|
.nav-group {
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
-->
|
|
14
14
|
<template>
|
|
15
15
|
<BForm
|
|
16
|
+
ref="formRoot"
|
|
16
17
|
@submit.prevent="handleSubmit"
|
|
17
|
-
:class="{ 'dx-form--horizontal':
|
|
18
|
+
:class="{ 'dx-form--horizontal': resolvedLayout === 'horizontal' }"
|
|
18
19
|
>
|
|
19
20
|
<!-- Form-level error message -->
|
|
20
21
|
<DAlert
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
:field="field"
|
|
72
73
|
:form="resolvedForm"
|
|
73
74
|
:model="model"
|
|
74
|
-
:layout="
|
|
75
|
+
:layout="resolvedLayout"
|
|
75
76
|
:label-cols="labelCols"
|
|
76
77
|
>
|
|
77
78
|
<!-- Forward every DXForm slot so the field can render
|
|
@@ -105,7 +106,7 @@
|
|
|
105
106
|
:field="field"
|
|
106
107
|
:form="resolvedForm"
|
|
107
108
|
:model="model"
|
|
108
|
-
:layout="
|
|
109
|
+
:layout="resolvedLayout"
|
|
109
110
|
:label-cols="labelCols"
|
|
110
111
|
>
|
|
111
112
|
<!-- Forward every DXForm slot so the field can render its
|
|
@@ -166,7 +167,7 @@
|
|
|
166
167
|
</template>
|
|
167
168
|
|
|
168
169
|
<script setup lang="ts">
|
|
169
|
-
import { computed, watch } from "vue";
|
|
170
|
+
import { computed, ref, watch, type ComponentPublicInstance } from "vue";
|
|
170
171
|
import { BForm } from "bootstrap-vue-next";
|
|
171
172
|
import DAlert from "../base/DAlert.vue";
|
|
172
173
|
import DButton from "../base/DButton.vue";
|
|
@@ -176,6 +177,7 @@ import { BTab as DTab } from "bootstrap-vue-next"; // raw BTab: BTabs scans slot
|
|
|
176
177
|
import DXFormField from "./DXFormField.vue";
|
|
177
178
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
178
179
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
180
|
+
import { useContainerWidth } from "../../composables/useContainerWidth";
|
|
179
181
|
import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
|
|
180
182
|
import {
|
|
181
183
|
resolvePredicate as resolvePredicateFor,
|
|
@@ -235,12 +237,34 @@ interface Props {
|
|
|
235
237
|
cardTabs?: boolean;
|
|
236
238
|
|
|
237
239
|
/**
|
|
238
|
-
* Form-wide field layout:
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
240
|
+
* Form-wide field layout:
|
|
241
|
+
*
|
|
242
|
+
* - `"vertical"` (default) — label above input, always.
|
|
243
|
+
* - `"horizontal"` — label left, input right, always.
|
|
244
|
+
* - `"auto"` — horizontal when the form's **own container** is at least
|
|
245
|
+
* `layoutThreshold` px wide, vertical below that. Container-driven, not
|
|
246
|
+
* viewport-driven: a page narrowed by the dashboard sidebar, or a form
|
|
247
|
+
* inside a modal, stacks even though the window is wide (which no
|
|
248
|
+
* Bootstrap media query can see).
|
|
249
|
+
*
|
|
250
|
+
* Overridable per-field via `field.layout`. A field with `span: true`
|
|
251
|
+
* always renders full-width, regardless of layout.
|
|
242
252
|
*/
|
|
243
|
-
layout?: "vertical" | "horizontal";
|
|
253
|
+
layout?: "vertical" | "horizontal" | "auto";
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Container width (px) at or above which `layout: "auto"` goes horizontal.
|
|
257
|
+
* Ignored for the explicit `"vertical"`/`"horizontal"` layouts.
|
|
258
|
+
*
|
|
259
|
+
* Default 640, measured rather than guessed: with the default 3-column
|
|
260
|
+
* label the label's text area is `containerWidth / 4 - 18` px, so 640 gives
|
|
261
|
+
* it 142px — enough for a ~20-character label ("Unit price (ex VAT)"
|
|
262
|
+
* measures 128px at the theme's label font) to stay on one line, with the
|
|
263
|
+
* control column still 474px. Below ~584px that label starts wrapping,
|
|
264
|
+
* which is the cramped-label symptom this exists to avoid. Raise it if your
|
|
265
|
+
* labels run longer, or lower `labelCols` instead.
|
|
266
|
+
*/
|
|
267
|
+
layoutThreshold?: number;
|
|
244
268
|
|
|
245
269
|
/**
|
|
246
270
|
* Label column width for horizontal layout (mirrors BFormGroup's
|
|
@@ -258,6 +282,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
258
282
|
card: false,
|
|
259
283
|
cardTabs: true,
|
|
260
284
|
layout: "vertical",
|
|
285
|
+
layoutThreshold: 640,
|
|
261
286
|
});
|
|
262
287
|
|
|
263
288
|
const emit = defineEmits<{
|
|
@@ -270,6 +295,55 @@ const slots = defineSlots<Record<string, (props: any) => any>>();
|
|
|
270
295
|
/** v-model for the active tab index. */
|
|
271
296
|
const activeTab = defineModel<number>("activeTab", { default: 0 });
|
|
272
297
|
|
|
298
|
+
// ————————————————— container-driven layout (`layout: "auto"`)
|
|
299
|
+
|
|
300
|
+
const formRoot = ref<ComponentPublicInstance | HTMLElement | null>(null);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `<form>` element BForm renders. A template ref on a component yields the
|
|
304
|
+
* component instance, so unwrap `$el` — and tolerate either shape rather than
|
|
305
|
+
* assuming one, so a future BForm change (or a functional re-implementation)
|
|
306
|
+
* can't silently leave us observing nothing.
|
|
307
|
+
*/
|
|
308
|
+
function resolveFormElement(): HTMLElement | null {
|
|
309
|
+
const rootValue = formRoot.value;
|
|
310
|
+
if (rootValue === null || rootValue === undefined) return null;
|
|
311
|
+
if (rootValue instanceof HTMLElement) return rootValue;
|
|
312
|
+
const element = (rootValue as ComponentPublicInstance).$el;
|
|
313
|
+
return element instanceof HTMLElement ? element : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Observe ONLY in auto mode. Returning null for the other layouts means no
|
|
317
|
+
// ResizeObserver is ever attached for the (overwhelmingly common) explicit
|
|
318
|
+
// layouts — existing consumers pay nothing and behave identically.
|
|
319
|
+
const { isBelow: containerIsNarrow } = useContainerWidth(
|
|
320
|
+
() => (props.layout === "auto" ? resolveFormElement() : null),
|
|
321
|
+
{
|
|
322
|
+
// Getter, so a consumer binding `:layout-threshold` to something
|
|
323
|
+
// reactive re-evaluates instead of latching the mount-time value.
|
|
324
|
+
threshold: () => props.layoutThreshold,
|
|
325
|
+
// Guard against an observer feedback loop at the boundary: the vertical
|
|
326
|
+
// layout is TALLER, so a form inside an `overflow:auto` ancestor can
|
|
327
|
+
// gain a scrollbar when it stacks, shrinking its own container by
|
|
328
|
+
// ~15-17px — straight back over the threshold, and it flips forever.
|
|
329
|
+
// A band wider than any scrollbar (24px) makes the crossing one-way
|
|
330
|
+
// until the container genuinely grows.
|
|
331
|
+
hysteresis: 24,
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The layout actually rendered. `vertical`/`horizontal` pass through
|
|
337
|
+
* unconditionally (unchanged behaviour); `auto` resolves from the measured
|
|
338
|
+
* container width, defaulting to `vertical` before the first measurement and
|
|
339
|
+
* under SSR — the stacked layout is legible at any width, so it is the safe
|
|
340
|
+
* thing to render when the width is unknown.
|
|
341
|
+
*/
|
|
342
|
+
const resolvedLayout = computed<"vertical" | "horizontal">(() => {
|
|
343
|
+
if (props.layout !== "auto") return props.layout;
|
|
344
|
+
return containerIsNarrow.value ? "vertical" : "horizontal";
|
|
345
|
+
});
|
|
346
|
+
|
|
273
347
|
// ————————————————— resolve form / fields (accept useForm or defineForm)
|
|
274
348
|
|
|
275
349
|
function isDefineForm(
|
|
@@ -163,6 +163,35 @@
|
|
|
163
163
|
<div class="fw-semibold">{{ headerLabel(field, label) }}</div>
|
|
164
164
|
<small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
|
|
165
165
|
</div>
|
|
166
|
+
|
|
167
|
+
<!-- ADDITIVE per-column header content (#99), rendered
|
|
168
|
+
INSIDE DXTable's own header cell rather than as a
|
|
169
|
+
`head(<key>)` override — an override would silently
|
|
170
|
+
drop the sort indicator and the field hint, which is
|
|
171
|
+
exactly why `head(...)` is not forwarded (see
|
|
172
|
+
`isTableSlot`). Placed after the label block and
|
|
173
|
+
BEFORE the sort indicator so a period total sits at
|
|
174
|
+
the top-right of the text area without displacing the
|
|
175
|
+
sort arrows, and lands in the same place whether or
|
|
176
|
+
not the column happens to be sortable. The wrapper is
|
|
177
|
+
only emitted when the slot exists, so a table without
|
|
178
|
+
one renders exactly as before. -->
|
|
179
|
+
<div
|
|
180
|
+
v-if="$slots[`head-end(${field.key})`]"
|
|
181
|
+
class="dx-head-end flex-shrink-0 text-end"
|
|
182
|
+
>
|
|
183
|
+
<!--
|
|
184
|
+
@slot head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.
|
|
185
|
+
@binding {object} field The column's field definition.
|
|
186
|
+
@binding {string} label The column's resolved header label.
|
|
187
|
+
-->
|
|
188
|
+
<slot
|
|
189
|
+
:name="`head-end(${field.key})`"
|
|
190
|
+
:field="field"
|
|
191
|
+
:label="headerLabel(field, label)"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
166
195
|
<div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
|
|
167
196
|
<span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
|
|
168
197
|
<span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
|
|
@@ -359,6 +388,22 @@ export interface TableField {
|
|
|
359
388
|
filterOptions?: FilterOption[];
|
|
360
389
|
filterPlaceholder?: string;
|
|
361
390
|
|
|
391
|
+
/**
|
|
392
|
+
* For a `select` filter on a **client-side** table: derive the dropdown's
|
|
393
|
+
* options from the distinct values present in the loaded rows. Defaults to
|
|
394
|
+
* `true` — with no `filterOptions` and no server `filterValues` the
|
|
395
|
+
* alternative is an empty dropdown, which can only ever be a bug.
|
|
396
|
+
*
|
|
397
|
+
* Set `false` to opt out (e.g. a column whose raw values are opaque ids you
|
|
398
|
+
* would rather label yourself via `filterOptions`).
|
|
399
|
+
*
|
|
400
|
+
* Explicit `filterOptions` and server-supplied `filterValues` still WIN, in
|
|
401
|
+
* that order. Provider/API and Inertia modes ignore this flag: they only
|
|
402
|
+
* ever hold one page of rows, so there is no complete value set to derive
|
|
403
|
+
* from (see `getFieldFilterOptions`).
|
|
404
|
+
*/
|
|
405
|
+
deriveFilterOptions?: boolean;
|
|
406
|
+
|
|
362
407
|
/**
|
|
363
408
|
* Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
|
|
364
409
|
* is treated as pixels (`200` → `200px`); a string is used verbatim
|
|
@@ -559,6 +604,18 @@ export interface Props<TItem = any> {
|
|
|
559
604
|
* borderless variant — the same header, filter row, table and pagination
|
|
560
605
|
* rendered directly on the page background (no card border/shadow), for
|
|
561
606
|
* data-heavy admin index pages that read as busy inside a card.
|
|
607
|
+
*
|
|
608
|
+
* The table renders FLUSH to the card border (rows and stripes reach the
|
|
609
|
+
* edge) while the header and pagination stay padded. Only the table region
|
|
610
|
+
* is rounded to follow the card's corners — the card itself does not clip
|
|
611
|
+
* (#166), so a `position: absolute` popover you render into `#header` or
|
|
612
|
+
* any other slot is free to overhang the card.
|
|
613
|
+
*
|
|
614
|
+
* The one place content is still clipped is INSIDE the table: Bootstrap's
|
|
615
|
+
* `.table-responsive` is a horizontal scroll container, so an overlay in a
|
|
616
|
+
* `cell(<key>)` slot is bounded by it. Teleport it (bvn's own menus already
|
|
617
|
+
* do — `DDropdown` teleports to `<body>`, the column-filter menus are
|
|
618
|
+
* `fixed`), or pass `:responsive="false"`.
|
|
562
619
|
*/
|
|
563
620
|
card?: boolean;
|
|
564
621
|
|
|
@@ -585,8 +642,13 @@ export interface Props<TItem = any> {
|
|
|
585
642
|
* **"horizontal"** to match the Omni Tend form convention (a raw modal form
|
|
586
643
|
* previously fell back to DXForm's vertical default, which looked out of
|
|
587
644
|
* place next to horizontal page forms). Pass `"vertical"` to opt out.
|
|
645
|
+
*
|
|
646
|
+
* `"auto"` hands the decision to `DXForm`'s container-width observer, which
|
|
647
|
+
* measures the FORM rather than the window — so a modal narrowed by its own
|
|
648
|
+
* breakpoint stacks to vertical even on a wide viewport, where a media query
|
|
649
|
+
* would still report "plenty of room". See `useContainerWidth`.
|
|
588
650
|
*/
|
|
589
|
-
editLayout?: 'vertical' | 'horizontal';
|
|
651
|
+
editLayout?: 'vertical' | 'horizontal' | 'auto';
|
|
590
652
|
|
|
591
653
|
/**
|
|
592
654
|
* Label column width for the edit modal's horizontal layout, forwarded to
|
|
@@ -1020,16 +1082,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1020
1082
|
return filterKeys.every(key => {
|
|
1021
1083
|
// Resolve by the FILTER key, which may differ from the column's key.
|
|
1022
1084
|
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
1023
|
-
|
|
1024
|
-
// not carry it at all (a `customer_name` row filtered on
|
|
1025
|
-
// `customer_id`). Match on the filter key when the row has it,
|
|
1026
|
-
// otherwise fall back to the column's own value — which is also the
|
|
1027
|
-
// one the user can actually see and type against.
|
|
1028
|
-
// Both lookups are dot-path aware, so a nested payload filters (#121).
|
|
1029
|
-
const itemValue =
|
|
1030
|
-
field && hasFieldValue(item, key)
|
|
1031
|
-
? fieldValueOf(item, key)
|
|
1032
|
-
: fieldValueOf(item, field?.key ?? key);
|
|
1085
|
+
const itemValue = clientSideFilterValueOf(item, field, key);
|
|
1033
1086
|
|
|
1034
1087
|
const matchesCandidate = (candidate: string): boolean => {
|
|
1035
1088
|
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
@@ -1151,6 +1204,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
|
1151
1204
|
return getByPath(item as Record<string, any>, key) !== undefined;
|
|
1152
1205
|
};
|
|
1153
1206
|
|
|
1207
|
+
/*
|
|
1208
|
+
* The value a client-side filter compares against, for one row and one column.
|
|
1209
|
+
*
|
|
1210
|
+
* `filterKey` names the param the SERVER filters on; a local row may not carry
|
|
1211
|
+
* it at all (a `customer_name` row filtered on `customer_id`). Read the filter
|
|
1212
|
+
* key when the row has it, otherwise fall back to the column's own value —
|
|
1213
|
+
* which is also the one the user can actually see and type against. Both
|
|
1214
|
+
* lookups are dot-path aware, so a nested payload filters (#121).
|
|
1215
|
+
*
|
|
1216
|
+
* Extracted so the client-side row filter and the DERIVED select options (see
|
|
1217
|
+
* `derivedFilterOptions`) resolve values through exactly the same rule — a
|
|
1218
|
+
* derived option must never be a value the filter then fails to match.
|
|
1219
|
+
*/
|
|
1220
|
+
const clientSideFilterValueOf = (
|
|
1221
|
+
item: unknown,
|
|
1222
|
+
field: TableField | undefined,
|
|
1223
|
+
filterKey: string,
|
|
1224
|
+
): any =>
|
|
1225
|
+
field && hasFieldValue(item, filterKey)
|
|
1226
|
+
? fieldValueOf(item, filterKey)
|
|
1227
|
+
: fieldValueOf(item, field?.key ?? filterKey);
|
|
1228
|
+
|
|
1154
1229
|
/*
|
|
1155
1230
|
* Dotted-key columns DXTable must render itself, because bvn renders them empty
|
|
1156
1231
|
* (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
|
|
@@ -1235,6 +1310,115 @@ const filterAllLabelFor = (field: TableField): string =>
|
|
|
1235
1310
|
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1236
1311
|
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1237
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* A select filter option's LABEL, honouring the column's own value→label
|
|
1315
|
+
* formatting so the dropdown reads the way the column does (a `formatter`
|
|
1316
|
+
* turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
|
|
1317
|
+
* the client-side filter compares against (`String(itemValue) === candidate`),
|
|
1318
|
+
* so formatting must never leak into it.
|
|
1319
|
+
*
|
|
1320
|
+
* `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
|
|
1321
|
+
* its first argument is by contract the value of `field.key` — the DISPLAYED
|
|
1322
|
+
* column value. That matters when `filterKey` names a different property: the
|
|
1323
|
+
* option's value must stay the FILTER value (that is what the row filter
|
|
1324
|
+
* compares), but the formatter must still be handed the display value it was
|
|
1325
|
+
* written against. Passing the filter value here fed a `customer` column's
|
|
1326
|
+
* `(customer) => customer.name` formatter a numeric `customer_id`, which
|
|
1327
|
+
* mislabels at best and throws at worst.
|
|
1328
|
+
*/
|
|
1329
|
+
const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
|
|
1330
|
+
if (typeof field.formatter !== 'function') return null;
|
|
1331
|
+
const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
|
|
1332
|
+
if (formatted === null || formatted === undefined) return null;
|
|
1333
|
+
return String(formatted);
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Client-side auto-derived `filter: "select"` options, keyed by field key.
|
|
1338
|
+
*
|
|
1339
|
+
* Without this a client-side select column with no `filterOptions` and no
|
|
1340
|
+
* server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
|
|
1341
|
+
* bug — so this is on by default (opt out per column with
|
|
1342
|
+
* `deriveFilterOptions: false`).
|
|
1343
|
+
*
|
|
1344
|
+
* It derives from `resolvedItems` (the FULL loaded row set) and deliberately
|
|
1345
|
+
* NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
|
|
1346
|
+
* - filtered set → picking a value would collapse the dropdown to that one
|
|
1347
|
+
* value, and the user could never get back to any other one;
|
|
1348
|
+
* - current page → the offered values would silently change as you page.
|
|
1349
|
+
* Being a computed over `props.fields` + `resolvedItems` only, it is also
|
|
1350
|
+
* structurally incapable of depending on the active filters — the cache is the
|
|
1351
|
+
* guard, not just the comment.
|
|
1352
|
+
*/
|
|
1353
|
+
const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
|
|
1354
|
+
// Only client-side mode has the complete row set. Provider/API and Inertia
|
|
1355
|
+
// hold one page, so they take their options from the server instead.
|
|
1356
|
+
if (!isClientSideMode.value) return {};
|
|
1357
|
+
|
|
1358
|
+
const rows = resolvedItems.value;
|
|
1359
|
+
if (!rows || rows.length === 0) return {};
|
|
1360
|
+
|
|
1361
|
+
const optionsByFieldKey: Record<string, FilterOption[]> = {};
|
|
1362
|
+
|
|
1363
|
+
for (const field of props.fields) {
|
|
1364
|
+
if (field.filter !== 'select') continue;
|
|
1365
|
+
if (field.deriveFilterOptions === false) continue;
|
|
1366
|
+
// Explicit options win outright (see `getFieldFilterOptions`), so don't
|
|
1367
|
+
// spend a pass over every row producing a list nothing will read.
|
|
1368
|
+
if (field.filterOptions && field.filterOptions.length > 0) continue;
|
|
1369
|
+
|
|
1370
|
+
const filterKey = filterKeyFor(field);
|
|
1371
|
+
|
|
1372
|
+
// Distinct values keyed by their STRING form, because that is exactly
|
|
1373
|
+
// what the client-side select filter compares — so `1` and `"1"` are one
|
|
1374
|
+
// option, and every option offered is one the filter can match. The
|
|
1375
|
+
// first row carrying a value is kept alongside it, as the `item`
|
|
1376
|
+
// argument the column's formatter expects.
|
|
1377
|
+
const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
|
|
1378
|
+
|
|
1379
|
+
// Candidates the filter reads as CONTROL values rather than data, so a
|
|
1380
|
+
// row whose value collides with one can never be matched by selecting it
|
|
1381
|
+
// (`handleSelectFilterChange` turns the "all" sentinel back into "no
|
|
1382
|
+
// filter", and the null sentinel is intercepted upstream of the select's
|
|
1383
|
+
// exact-match arm, where it means "has no value"). Offering such an
|
|
1384
|
+
// option would be a dropdown entry that returns zero rows, and the null
|
|
1385
|
+
// one would also duplicate the `filterNullText` entry's value.
|
|
1386
|
+
const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
|
|
1387
|
+
if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
|
|
1388
|
+
|
|
1389
|
+
for (const item of rows) {
|
|
1390
|
+
const value = clientSideFilterValueOf(item, field, filterKey);
|
|
1391
|
+
// null / undefined / '' are skipped deliberately: "has no value" is
|
|
1392
|
+
// already a first-class, LABELLED option via `filterNullText`, and
|
|
1393
|
+
// an unlabelled blank row in the list would be unusable.
|
|
1394
|
+
if (value === null || value === undefined || value === '') continue;
|
|
1395
|
+
const stringValue = String(value);
|
|
1396
|
+
if (reservedCandidates.has(stringValue)) continue;
|
|
1397
|
+
if (!firstRowByValue.has(stringValue)) {
|
|
1398
|
+
firstRowByValue.set(stringValue, { rawValue: value, item });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const entries = [...firstRowByValue.entries()];
|
|
1403
|
+
|
|
1404
|
+
// Stable, human ordering: numeric when both values are numbers,
|
|
1405
|
+
// otherwise a locale compare — the same rule the client-side sort uses,
|
|
1406
|
+
// with `numeric: true` so "Item 2" precedes "Item 10".
|
|
1407
|
+
entries.sort(([leftText, left], [rightText, right]) =>
|
|
1408
|
+
typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
|
|
1409
|
+
? left.rawValue - right.rawValue
|
|
1410
|
+
: leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
|
|
1414
|
+
value: stringValue,
|
|
1415
|
+
text: formattedFilterLabel(field, item) ?? stringValue,
|
|
1416
|
+
}));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return optionsByFieldKey;
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1238
1422
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1239
1423
|
const options: FilterOption[] = [];
|
|
1240
1424
|
|
|
@@ -1274,6 +1458,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1274
1458
|
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1275
1459
|
}
|
|
1276
1460
|
|
|
1461
|
+
// Last resort, CLIENT-SIDE only: the distinct values present in the loaded
|
|
1462
|
+
// rows. Precedence is deliberate — explicit `filterOptions`, then the
|
|
1463
|
+
// server's `filterValues`, then what the data itself says. See
|
|
1464
|
+
// `derivedFilterOptions` for why it derives from the unfiltered row set.
|
|
1465
|
+
const derived = derivedFilterOptions.value[field.key];
|
|
1466
|
+
if (derived && derived.length > 0) {
|
|
1467
|
+
return [...options, ...derived];
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1277
1470
|
// No values to choose from — don't offer a lone "All x" that filters nothing.
|
|
1278
1471
|
return [];
|
|
1279
1472
|
};
|
|
@@ -1549,7 +1742,12 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1549
1742
|
return total >= smallestOption;
|
|
1550
1743
|
});
|
|
1551
1744
|
|
|
1552
|
-
// Detect which fields need server filter values
|
|
1745
|
+
// Detect which fields need server filter values.
|
|
1746
|
+
//
|
|
1747
|
+
// Client-side derived options (`derivedFilterOptions`) deliberately do NOT
|
|
1748
|
+
// shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
|
|
1749
|
+
// and `isProviderMode` requires `!resolvedClientSide` — the two modes are
|
|
1750
|
+
// mutually exclusive, so a client-side derived column can never appear here.
|
|
1553
1751
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1554
1752
|
return props.fields
|
|
1555
1753
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
@@ -1956,6 +2154,12 @@ const refresh = () => {
|
|
|
1956
2154
|
* - `head(<key>)` — DXTable draws its own column headers (sort indicators,
|
|
1957
2155
|
* field hints). Forwarding a consumer's would silently drop those.
|
|
1958
2156
|
* - `thead-top` — DXTable renders the inline filter row there.
|
|
2157
|
+
*
|
|
2158
|
+
* `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
|
|
2159
|
+
* not forwarded: the inner table has no such slot, and the prefixes below are
|
|
2160
|
+
* matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
|
|
2161
|
+
* `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
|
|
2162
|
+
* (which filters on `isTableSlot`), so adding one never remounts the table.
|
|
1959
2163
|
*/
|
|
1960
2164
|
const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
|
|
1961
2165
|
const TABLE_SLOT_NAMES = new Set([
|
|
@@ -208,8 +208,13 @@ interface Props {
|
|
|
208
208
|
pendingAction: 'save' | 'delete' | null;
|
|
209
209
|
/** Delete endpoint — gates the Delete button. */
|
|
210
210
|
deleteUrl?: string;
|
|
211
|
-
/**
|
|
212
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Field layout forwarded to the modal's DXForm (defaults handled upstream).
|
|
213
|
+
* Includes `"auto"`, which lets the form stack to vertical when the MODAL is
|
|
214
|
+
* narrow even though the viewport is wide — the container-query case a
|
|
215
|
+
* viewport media query cannot see.
|
|
216
|
+
*/
|
|
217
|
+
layout?: 'vertical' | 'horizontal' | 'auto';
|
|
213
218
|
/** Label column width forwarded to DXForm (horizontal layout). */
|
|
214
219
|
labelCols?: LabelCols;
|
|
215
220
|
/** Wrap the modal form in a card (opt-in — off by default). */
|