@omnitend/dashboard-for-laravel 0.29.0 → 0.30.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/base/DAutocomplete.vue.d.ts +21 -1
- package/dist/components/extended/DXForm.vue.d.ts +14 -3
- package/dist/components/extended/DXTable.vue.d.ts +39 -1
- package/dist/components/extended/DXTablePagination.vue.d.ts +2 -0
- package/dist/dashboard-for-laravel.js +8092 -8020
- 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/style.css +1 -1
- package/docs/public/api-reference.json +52 -6
- package/docs/public/docs-map.md +1 -1
- package/package.json +1 -1
- package/resources/js/components/base/DAutocomplete.vue +96 -4
- package/resources/js/components/extended/DXForm.vue +25 -5
- package/resources/js/components/extended/DXTable.vue +139 -19
- package/resources/js/components/extended/DXTableEditorModal.vue +4 -0
- package/resources/js/components/extended/DXTablePagination.vue +5 -2
- package/resources/js/composables/useForm.ts +20 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated": "2026-07-
|
|
2
|
+
"generated": "2026-07-20T07:09:59.465Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.30.0"
|
|
6
6
|
},
|
|
7
7
|
"components": {
|
|
8
8
|
"base": [
|
|
@@ -102,8 +102,27 @@
|
|
|
102
102
|
"name": "DAutocomplete",
|
|
103
103
|
"category": "base",
|
|
104
104
|
"filePath": "resources/js/components/base/DAutocomplete.vue",
|
|
105
|
-
"description": "Type-safe wrapper around Bootstrap Vue Next BAutocomplete component. Text input with a filtered dropdown of suggestions (typeahead/combobox). Pass `options` and bind `v-model`; supports `multiple` and a custom `filterFunction`.",
|
|
106
|
-
"props": [
|
|
105
|
+
"description": "Type-safe wrapper around Bootstrap Vue Next BAutocomplete component. Text input with a filtered dropdown of suggestions (typeahead/combobox). Pass `options` and bind `v-model`; supports `multiple` and a custom `filterFunction`. `null-option` adds an opt-in, pinned \"no selection\" row (see below) so a searchable single-select can always reach \"none\" in-list (#138).",
|
|
106
|
+
"props": [
|
|
107
|
+
{
|
|
108
|
+
"name": "options",
|
|
109
|
+
"type": "AutocompleteOptions",
|
|
110
|
+
"required": false,
|
|
111
|
+
"description": "Options passed straight through to BAutocomplete (see its `options` prop)."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "filterFunction",
|
|
115
|
+
"type": "FilterFunction",
|
|
116
|
+
"required": false,
|
|
117
|
+
"description": "A custom filter. Composed with `null-option` rather than replaced: when a\nnull option is present it stays pinned and this runs on the rest."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "nullOption",
|
|
121
|
+
"type": "union",
|
|
122
|
+
"required": false,
|
|
123
|
+
"description": "Prepend a pinned, never-filtered, selectable \"no selection\" row whose value\nis `null` (#138). `true` → default label \"None\"; a string → that label.\nSelecting it sets the model to `null` (bvn reads that as no selection, so\nthe ✕ hides and the placeholder shows). Off by default — unchanged\nbehaviour. DXTable does NOT use this; it builds its own \"All\" option."
|
|
124
|
+
}
|
|
125
|
+
],
|
|
107
126
|
"events": [],
|
|
108
127
|
"slots": [
|
|
109
128
|
{
|
|
@@ -2114,7 +2133,14 @@
|
|
|
2114
2133
|
"type": "boolean",
|
|
2115
2134
|
"required": false,
|
|
2116
2135
|
"default": "false",
|
|
2117
|
-
"description": "Wrap the form in a card for a visual boundary (mirrors DXTable's\n`card` prop).
|
|
2136
|
+
"description": "Wrap the form in a card for a visual boundary (mirrors DXTable's\n`card` prop). Off by default since DXForm is commonly embedded in a\npage card or modal already. Tabbed forms are wrapped by default\nregardless — see `cardTabs`; setting `card` also forces the tabbed\ncard on even when `cardTabs` is disabled."
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
"name": "cardTabs",
|
|
2140
|
+
"type": "boolean",
|
|
2141
|
+
"required": false,
|
|
2142
|
+
"default": "true",
|
|
2143
|
+
"description": "Wrap a TABBED form's content in a card panel so the active tab reads\nas a finished panel connected to the tab strip (the standard\nBootstrap card-with-tabs pattern) rather than floating on the bare\npage background (#159). On by default. Set `false` for bare tabs\n(e.g. inside a modal that already provides a boundary). Ignored for\nflat (non-tabbed) forms — use `card` for those."
|
|
2118
2144
|
},
|
|
2119
2145
|
{
|
|
2120
2146
|
"name": "layout",
|
|
@@ -2779,6 +2805,13 @@
|
|
|
2779
2805
|
"default": "true",
|
|
2780
2806
|
"description": "Show per-page selector"
|
|
2781
2807
|
},
|
|
2808
|
+
{
|
|
2809
|
+
"name": "showCount",
|
|
2810
|
+
"type": "boolean",
|
|
2811
|
+
"required": false,
|
|
2812
|
+
"default": "true",
|
|
2813
|
+
"description": "Show the footer's item-count caption (\"N items.\" / \"X to Y out of Z\nitems.\"). Defaults to `true`. Set `false` to suppress just the caption,\nindependent of the pager — a page ported from a plain `<b-table>` that\nhides the pager with `:show-pagination=\"false\"` would otherwise gain a\ncaption it never had (#127)."
|
|
2814
|
+
},
|
|
2782
2815
|
{
|
|
2783
2816
|
"name": "perPageOptions",
|
|
2784
2817
|
"type": "Array",
|
|
@@ -2797,7 +2830,7 @@
|
|
|
2797
2830
|
"name": "perPage",
|
|
2798
2831
|
"type": "number",
|
|
2799
2832
|
"required": false,
|
|
2800
|
-
"description": "Items per page (
|
|
2833
|
+
"description": "Items per page. With a `v-model:per-page` it is controlled state; a bare\n`:per-page=\"20\"` is the INITIAL page size (see the initial-value vs\ncontrolled note above). When passed explicitly it takes PRECEDENCE over any\nper-page preference persisted in localStorage — `:per-page=\"20\"` always\nstarts at 20, never a size the user once picked on another table (#124).\nOmit it to let a stored preference (URL-keyed tables only) or the default\napply."
|
|
2801
2834
|
},
|
|
2802
2835
|
{
|
|
2803
2836
|
"name": "striped",
|
|
@@ -2953,6 +2986,13 @@
|
|
|
2953
2986
|
"type": "boolean",
|
|
2954
2987
|
"required": false,
|
|
2955
2988
|
"description": "Enable client-side filtering, sorting, and pagination on items array"
|
|
2989
|
+
},
|
|
2990
|
+
{
|
|
2991
|
+
"name": "fixedLayout",
|
|
2992
|
+
"type": "boolean",
|
|
2993
|
+
"required": false,
|
|
2994
|
+
"default": "false",
|
|
2995
|
+
"description": "Apply `table-layout: fixed` to the underlying table. Off by default, so\nexisting tables keep their content-sized columns. When on, column widths\nstop depending on cell content, so they no longer reshuffle when a filter\nnarrows the rows — the table stays put as you filter (#156). Combine with\nper-field `width`/`minWidth` to control the proportions; columns with no\ndeclared width share the remaining space equally."
|
|
2956
2996
|
}
|
|
2957
2997
|
],
|
|
2958
2998
|
"events": [
|
|
@@ -3392,6 +3432,12 @@
|
|
|
3392
3432
|
"required": true,
|
|
3393
3433
|
"description": "Whether to render the per-page selector."
|
|
3394
3434
|
},
|
|
3435
|
+
{
|
|
3436
|
+
"name": "showCount",
|
|
3437
|
+
"type": "boolean",
|
|
3438
|
+
"required": true,
|
|
3439
|
+
"description": "Whether to render the item-count caption (the \"X out of Z items\" line)."
|
|
3440
|
+
},
|
|
3395
3441
|
{
|
|
3396
3442
|
"name": "singularItemName",
|
|
3397
3443
|
"type": "string",
|
package/docs/public/docs-map.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Documentation Map
|
|
2
2
|
|
|
3
3
|
> Auto-generated hierarchical overview of all documentation
|
|
4
|
-
> Last updated: 2026-07-
|
|
4
|
+
> Last updated: 2026-07-20T07:09:59.531Z
|
|
5
5
|
|
|
6
6
|
This file provides a complete map of all available documentation for AI agents and developers.
|
|
7
7
|
|
package/package.json
CHANGED
|
@@ -4,10 +4,38 @@
|
|
|
4
4
|
* Text input with a filtered dropdown of suggestions (typeahead/combobox).
|
|
5
5
|
* Pass `options` and bind `v-model`; supports `multiple` and a custom
|
|
6
6
|
* `filterFunction`.
|
|
7
|
+
*
|
|
8
|
+
* `null-option` adds an opt-in, pinned "no selection" row (see below) so a
|
|
9
|
+
* searchable single-select can always reach "none" in-list (#138).
|
|
7
10
|
*/
|
|
8
11
|
<script setup lang="ts">
|
|
9
12
|
import { computed, useAttrs } from "vue";
|
|
10
13
|
import { BAutocomplete } from "bootstrap-vue-next";
|
|
14
|
+
import type { SelectOption } from "bootstrap-vue-next";
|
|
15
|
+
|
|
16
|
+
// bvn's own `options` prop type — kept identical so this stays a pass-through.
|
|
17
|
+
type AutocompleteOptions = readonly (object | string | number | boolean)[];
|
|
18
|
+
type FilterFunction = (options: SelectOption[], search: string) => SelectOption[];
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Options passed straight through to BAutocomplete (see its `options` prop). */
|
|
22
|
+
options?: AutocompleteOptions;
|
|
23
|
+
/**
|
|
24
|
+
* A custom filter. Composed with `null-option` rather than replaced: when a
|
|
25
|
+
* null option is present it stays pinned and this runs on the rest.
|
|
26
|
+
*/
|
|
27
|
+
filterFunction?: FilterFunction;
|
|
28
|
+
/**
|
|
29
|
+
* Prepend a pinned, never-filtered, selectable "no selection" row whose value
|
|
30
|
+
* is `null` (#138). `true` → default label "None"; a string → that label.
|
|
31
|
+
* Selecting it sets the model to `null` (bvn reads that as no selection, so
|
|
32
|
+
* the ✕ hides and the placeholder shows). Off by default — unchanged
|
|
33
|
+
* behaviour. DXTable does NOT use this; it builds its own "All" option.
|
|
34
|
+
*/
|
|
35
|
+
nullOption?: boolean | string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = defineProps<Props>();
|
|
11
39
|
|
|
12
40
|
defineOptions({
|
|
13
41
|
inheritAttrs: false,
|
|
@@ -15,6 +43,68 @@ defineOptions({
|
|
|
15
43
|
|
|
16
44
|
const attrs = useAttrs();
|
|
17
45
|
|
|
46
|
+
const attr = (name: string, hyphenated: string) =>
|
|
47
|
+
(attrs as Record<string, unknown>)[name] ?? (attrs as Record<string, unknown>)[hyphenated];
|
|
48
|
+
|
|
49
|
+
const DEFAULT_NULL_OPTION_LABEL = "None";
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* The null option is a raw option that bvn will normalise through the SAME
|
|
53
|
+
* `value-field`/`text-field` the consumer configured (default `value`/`text`).
|
|
54
|
+
* Build it with those effective field names so it normalises to
|
|
55
|
+
* `{ value: null, text: label }` — otherwise a consumer using e.g.
|
|
56
|
+
* `value-field="id" text-field="label"` would normalise our `{ value, text }`
|
|
57
|
+
* to `{ value: undefined, text: undefined }`, and the row would be filtered out
|
|
58
|
+
* and select as `undefined` instead of `null` (#138 review).
|
|
59
|
+
*/
|
|
60
|
+
const nullOption = computed<Record<string, unknown> | null>(() => {
|
|
61
|
+
const raw = props.nullOption;
|
|
62
|
+
if (raw === false || raw === undefined) return null;
|
|
63
|
+
const text = typeof raw === "string" ? raw : DEFAULT_NULL_OPTION_LABEL;
|
|
64
|
+
const valueField = (attr("valueField", "value-field") as string | undefined) ?? "value";
|
|
65
|
+
const textField = (attr("textField", "text-field") as string | undefined) ?? "text";
|
|
66
|
+
return { [valueField]: null, [textField]: text };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const consumerOptions = computed<AutocompleteOptions>(() =>
|
|
70
|
+
Array.isArray(props.options) ? props.options : [],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const computedOptions = computed<AutocompleteOptions>(() => {
|
|
74
|
+
const pinned = nullOption.value;
|
|
75
|
+
if (!pinned) return props.options ?? [];
|
|
76
|
+
return [pinned, ...consumerOptions.value];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const isNullOption = (option: SelectOption) => option?.value === null;
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* When a null option is present it must stay pinned (top of the list, immune to
|
|
83
|
+
* the search term); the consumer's `filterFunction` — or bvn's default
|
|
84
|
+
* substring-on-text match — applies only to the rest, so the two compose rather
|
|
85
|
+
* than clobber. With no null option we forward the consumer's filter unchanged
|
|
86
|
+
* (possibly undefined, letting bvn use its own default).
|
|
87
|
+
*/
|
|
88
|
+
const effectiveFilterFunction = computed<FilterFunction | undefined>(() => {
|
|
89
|
+
const pinned = nullOption.value;
|
|
90
|
+
const consumerFilter = props.filterFunction;
|
|
91
|
+
if (!pinned) return consumerFilter;
|
|
92
|
+
|
|
93
|
+
return (options, search) => {
|
|
94
|
+
const rest = options.filter((option) => !isNullOption(option));
|
|
95
|
+
const filteredRest = consumerFilter
|
|
96
|
+
? consumerFilter(rest, search)
|
|
97
|
+
: search
|
|
98
|
+
? rest.filter((option) =>
|
|
99
|
+
String(option.text ?? "")
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.includes(search.toLowerCase()),
|
|
102
|
+
)
|
|
103
|
+
: rest;
|
|
104
|
+
return [...options.filter(isNullOption), ...filteredRest];
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
18
108
|
/*
|
|
19
109
|
* Diverges from bvn: the clear (✕) is hidden when there is nothing to clear.
|
|
20
110
|
*
|
|
@@ -28,9 +118,6 @@ const attrs = useAttrs();
|
|
|
28
118
|
* A consumer's explicit `no-clear-button` still wins; this only ever hides the
|
|
29
119
|
* button, never forces it on.
|
|
30
120
|
*/
|
|
31
|
-
const attr = (name: string, hyphenated: string) =>
|
|
32
|
-
(attrs as Record<string, unknown>)[name] ?? (attrs as Record<string, unknown>)[hyphenated];
|
|
33
|
-
|
|
34
121
|
const hasValue = computed(() => {
|
|
35
122
|
const value = attr("modelValue", "model-value");
|
|
36
123
|
if (Array.isArray(value)) return value.length > 0;
|
|
@@ -52,7 +139,12 @@ const noClearButton = computed(
|
|
|
52
139
|
(#53). A plain element root makes it deterministic.
|
|
53
140
|
-->
|
|
54
141
|
<div class="d-autocomplete">
|
|
55
|
-
<BAutocomplete
|
|
142
|
+
<BAutocomplete
|
|
143
|
+
v-bind="$attrs"
|
|
144
|
+
:options="computedOptions"
|
|
145
|
+
:filter-function="effectiveFilterFunction"
|
|
146
|
+
:no-clear-button="noClearButton"
|
|
147
|
+
>
|
|
56
148
|
<!-- Dynamically pass through all named slots with their props -->
|
|
57
149
|
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
|
58
150
|
<slot :name="name" v-bind="slotProps" />
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
`.card-body` wrapper — DTabs already provides `.card-header`/
|
|
37
37
|
`.card-body` internally via its own `card` prop, so wrapping
|
|
38
38
|
that in another `.card-body` would double up. -->
|
|
39
|
-
<component :is="
|
|
40
|
-
<DTabs v-model:index="activeTab" :card="
|
|
39
|
+
<component :is="tabsInCard ? DCard : 'div'" v-if="hasTabs" v-bind="tabsInCard ? { noBody: true } : {}">
|
|
40
|
+
<DTabs v-model:index="activeTab" :card="tabsInCard">
|
|
41
41
|
<DTab
|
|
42
42
|
v-for="(tab, index) in visibleTabs"
|
|
43
43
|
:key="tab.key"
|
|
@@ -213,12 +213,23 @@ interface Props {
|
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
215
|
* Wrap the form in a card for a visual boundary (mirrors DXTable's
|
|
216
|
-
* `card` prop).
|
|
217
|
-
* card
|
|
218
|
-
*
|
|
216
|
+
* `card` prop). Off by default since DXForm is commonly embedded in a
|
|
217
|
+
* page card or modal already. Tabbed forms are wrapped by default
|
|
218
|
+
* regardless — see `cardTabs`; setting `card` also forces the tabbed
|
|
219
|
+
* card on even when `cardTabs` is disabled.
|
|
219
220
|
*/
|
|
220
221
|
card?: boolean;
|
|
221
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Wrap a TABBED form's content in a card panel so the active tab reads
|
|
225
|
+
* as a finished panel connected to the tab strip (the standard
|
|
226
|
+
* Bootstrap card-with-tabs pattern) rather than floating on the bare
|
|
227
|
+
* page background (#159). On by default. Set `false` for bare tabs
|
|
228
|
+
* (e.g. inside a modal that already provides a boundary). Ignored for
|
|
229
|
+
* flat (non-tabbed) forms — use `card` for those.
|
|
230
|
+
*/
|
|
231
|
+
cardTabs?: boolean;
|
|
232
|
+
|
|
222
233
|
/**
|
|
223
234
|
* Form-wide field layout: "vertical" (default, label above input) or
|
|
224
235
|
* "horizontal" (label left, input right). Overridable per-field via
|
|
@@ -241,6 +252,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
241
252
|
showSubmit: true,
|
|
242
253
|
autoErrorTab: true,
|
|
243
254
|
card: false,
|
|
255
|
+
cardTabs: true,
|
|
244
256
|
layout: "vertical",
|
|
245
257
|
});
|
|
246
258
|
|
|
@@ -318,6 +330,14 @@ const hasTabs = computed(
|
|
|
318
330
|
() => !!props.tabs && props.tabs.length > 0,
|
|
319
331
|
);
|
|
320
332
|
|
|
333
|
+
// Tabbed forms render inside a card panel by default (#159) so the tab
|
|
334
|
+
// content reads as a finished panel rather than floating on the page.
|
|
335
|
+
// `cardTabs` is the tabbed-only toggle (on by default); `card` (which also
|
|
336
|
+
// wraps flat forms) still forces it on. Flat forms are unaffected.
|
|
337
|
+
const tabsInCard = computed(
|
|
338
|
+
() => hasTabs.value && (props.card || props.cardTabs),
|
|
339
|
+
);
|
|
340
|
+
|
|
321
341
|
function visibleFieldsFor(tab: FormTab): FieldDefinition[] {
|
|
322
342
|
return tab.fieldKeys
|
|
323
343
|
.map((key) => fieldByKey.value[key])
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
:striped="striped"
|
|
58
58
|
:hover="hover"
|
|
59
59
|
:responsive="responsive"
|
|
60
|
+
:fixed="fixedLayout"
|
|
60
61
|
:tbody-tr-class="composeRowClass"
|
|
61
62
|
v-bind="{ ...tableModeBindings, ...tablePassthroughProps }"
|
|
62
63
|
@update:sort-by="handleSortChange"
|
|
@@ -65,6 +66,25 @@
|
|
|
65
66
|
@update:busy="handleBusyChange"
|
|
66
67
|
@row-clicked="handleRowClick"
|
|
67
68
|
>
|
|
69
|
+
<!-- Per-column widths (#156) via a `<colgroup>`. bvn nests this
|
|
70
|
+
slot's `<col>`s inside a `<colgroup>` it renders. Only
|
|
71
|
+
emitted when a field declares a width AND the consumer hasn't
|
|
72
|
+
supplied their own `table-colgroup` (forwarded generically) —
|
|
73
|
+
so a table with no widths gets no colgroup and renders exactly
|
|
74
|
+
as before. `<col>` widths are authoritative under
|
|
75
|
+
`table-layout: fixed`, where the filter row (not the header)
|
|
76
|
+
is the width-determining first row. -->
|
|
77
|
+
<template
|
|
78
|
+
v-if="fieldsHaveWidths && !$slots['table-colgroup']"
|
|
79
|
+
#table-colgroup
|
|
80
|
+
>
|
|
81
|
+
<col
|
|
82
|
+
v-for="field in fields"
|
|
83
|
+
:key="`col-${field.key}`"
|
|
84
|
+
:style="columnStyleFor(field)"
|
|
85
|
+
/>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
68
88
|
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
69
89
|
consumer's own thead-top content is COMPOSED above it rather
|
|
70
90
|
than being dropped: a grouped column-header banner or a pinned
|
|
@@ -177,6 +197,7 @@
|
|
|
177
197
|
:per-page-options="perPageOptions"
|
|
178
198
|
:show-pagination="showPagination"
|
|
179
199
|
:show-per-page-selector="shouldShowPerPageSelector"
|
|
200
|
+
:show-count="showCount"
|
|
180
201
|
:singular-item-name="singularItemName"
|
|
181
202
|
:plural-item-name="pluralItemName"
|
|
182
203
|
:has-active-filters="hasActiveFilters"
|
|
@@ -288,6 +309,21 @@ export interface TableField {
|
|
|
288
309
|
filter?: FilterType;
|
|
289
310
|
filterOptions?: FilterOption[];
|
|
290
311
|
filterPlaceholder?: string;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
|
|
315
|
+
* is treated as pixels (`200` → `200px`); a string is used verbatim
|
|
316
|
+
* (`"20%"`, `"12rem"`). Pair with the table-level `fixedLayout` prop so the
|
|
317
|
+
* width is authoritative and the column stops resizing to fit its content —
|
|
318
|
+
* without `fixedLayout` a wider cell can still stretch the column (#156).
|
|
319
|
+
*/
|
|
320
|
+
width?: string | number;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Minimum width for this column, applied via the same `<col>` as `width`.
|
|
324
|
+
* A number is pixels; a string is used verbatim.
|
|
325
|
+
*/
|
|
326
|
+
minWidth?: string | number;
|
|
291
327
|
[key: string]: any;
|
|
292
328
|
}
|
|
293
329
|
|
|
@@ -390,13 +426,30 @@ export interface Props<TItem = any> {
|
|
|
390
426
|
/** Show per-page selector */
|
|
391
427
|
showPerPageSelector?: boolean;
|
|
392
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Show the footer's item-count caption ("N items." / "X to Y out of Z
|
|
431
|
+
* items."). Defaults to `true`. Set `false` to suppress just the caption,
|
|
432
|
+
* independent of the pager — a page ported from a plain `<b-table>` that
|
|
433
|
+
* hides the pager with `:show-pagination="false"` would otherwise gain a
|
|
434
|
+
* caption it never had (#127).
|
|
435
|
+
*/
|
|
436
|
+
showCount?: boolean;
|
|
437
|
+
|
|
393
438
|
/** Per-page options for selector */
|
|
394
439
|
perPageOptions?: number[];
|
|
395
440
|
|
|
396
441
|
/** Current page (for provider mode) */
|
|
397
442
|
currentPage?: number;
|
|
398
443
|
|
|
399
|
-
/**
|
|
444
|
+
/**
|
|
445
|
+
* Items per page. With a `v-model:per-page` it is controlled state; a bare
|
|
446
|
+
* `:per-page="20"` is the INITIAL page size (see the initial-value vs
|
|
447
|
+
* controlled note above). When passed explicitly it takes PRECEDENCE over any
|
|
448
|
+
* per-page preference persisted in localStorage — `:per-page="20"` always
|
|
449
|
+
* starts at 20, never a size the user once picked on another table (#124).
|
|
450
|
+
* Omit it to let a stored preference (URL-keyed tables only) or the default
|
|
451
|
+
* apply.
|
|
452
|
+
*/
|
|
400
453
|
perPage?: number;
|
|
401
454
|
|
|
402
455
|
/** Striped (banded) rows. Off by default — banding adds little on short
|
|
@@ -525,6 +578,16 @@ export interface Props<TItem = any> {
|
|
|
525
578
|
|
|
526
579
|
/** Enable client-side filtering, sorting, and pagination on items array */
|
|
527
580
|
clientSide?: boolean;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Apply `table-layout: fixed` to the underlying table. Off by default, so
|
|
584
|
+
* existing tables keep their content-sized columns. When on, column widths
|
|
585
|
+
* stop depending on cell content, so they no longer reshuffle when a filter
|
|
586
|
+
* narrows the rows — the table stays put as you filter (#156). Combine with
|
|
587
|
+
* per-field `width`/`minWidth` to control the proportions; columns with no
|
|
588
|
+
* declared width share the remaining space equally.
|
|
589
|
+
*/
|
|
590
|
+
fixedLayout?: boolean;
|
|
528
591
|
}
|
|
529
592
|
|
|
530
593
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
@@ -542,6 +605,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
542
605
|
}),
|
|
543
606
|
showPagination: true,
|
|
544
607
|
showPerPageSelector: true,
|
|
608
|
+
showCount: true,
|
|
545
609
|
perPageOptions: () => [10, 20, 50, 100],
|
|
546
610
|
currentPage: 1,
|
|
547
611
|
// perPage: 10, // Don't set default - let internalPerPage handle it
|
|
@@ -556,6 +620,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
556
620
|
showCreateButton: true,
|
|
557
621
|
footClone: false,
|
|
558
622
|
showEmpty: true,
|
|
623
|
+
fixedLayout: false,
|
|
559
624
|
});
|
|
560
625
|
|
|
561
626
|
const emit = defineEmits<{
|
|
@@ -906,6 +971,35 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
|
906
971
|
const dottedCellFields = (slots: Record<string, unknown>): TableField[] =>
|
|
907
972
|
props.fields.filter((field) => field.key.includes('.') && !slots[`cell(${field.key})`]);
|
|
908
973
|
|
|
974
|
+
/*
|
|
975
|
+
* Per-column widths (#156). A field's `width`/`minWidth` is applied through a
|
|
976
|
+
* `<colgroup>` `<col>`, NOT the header `<th>`: with `table-layout: fixed` the
|
|
977
|
+
* column widths are taken from the FIRST row of cells, and DXTable's inline
|
|
978
|
+
* filter row (`thead-top`) is that first row — so a width on the header `<th>`
|
|
979
|
+
* would be ignored whenever filters are present. A `<col>` width is
|
|
980
|
+
* authoritative regardless of row order, in both fixed and auto layout.
|
|
981
|
+
*/
|
|
982
|
+
const sizeToCss = (value: string | number): string =>
|
|
983
|
+
typeof value === 'number' ? `${value}px` : value;
|
|
984
|
+
|
|
985
|
+
const hasDeclaredWidth = (field: TableField): boolean =>
|
|
986
|
+
(field.width !== undefined && field.width !== null) ||
|
|
987
|
+
(field.minWidth !== undefined && field.minWidth !== null);
|
|
988
|
+
|
|
989
|
+
const columnStyleFor = (field: TableField): Record<string, string> | undefined => {
|
|
990
|
+
const style: Record<string, string> = {};
|
|
991
|
+
if (field.width !== undefined && field.width !== null) style.width = sizeToCss(field.width);
|
|
992
|
+
if (field.minWidth !== undefined && field.minWidth !== null) {
|
|
993
|
+
style.minWidth = sizeToCss(field.minWidth);
|
|
994
|
+
}
|
|
995
|
+
return Object.keys(style).length > 0 ? style : undefined;
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
// Only inject a `<colgroup>` when a column actually declares a width — otherwise
|
|
999
|
+
// the table renders exactly as before (no colgroup at all), preserving the
|
|
1000
|
+
// content-sized default byte-for-byte.
|
|
1001
|
+
const fieldsHaveWidths = computed(() => props.fields.some(hasDeclaredWidth));
|
|
1002
|
+
|
|
909
1003
|
// Computed: Get effective filter options for a field
|
|
910
1004
|
// The key a column's filter is SENT under, which is not always the column's own
|
|
911
1005
|
// key: a display column can filter on a different server param via `filterKey`.
|
|
@@ -973,27 +1067,51 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
973
1067
|
return [];
|
|
974
1068
|
};
|
|
975
1069
|
|
|
976
|
-
// LocalStorage key for
|
|
977
|
-
|
|
978
|
-
|
|
1070
|
+
// LocalStorage key for the per-page preference. Only tables identified by a URL
|
|
1071
|
+
// (inertiaUrl / apiUrl) persist their preference: a keyless client-side table
|
|
1072
|
+
// has no stable identity across reloads, and every such table would otherwise
|
|
1073
|
+
// share the literal `table` key and clobber each other's stored value (#124).
|
|
1074
|
+
// Returns null when there is nothing stable to key on — every read and write
|
|
1075
|
+
// guards on that, so a keyless table simply doesn't persist.
|
|
1076
|
+
const perPageStorageKey = computed<string | null>(() => {
|
|
1077
|
+
const url = props.inertiaUrl || props.apiUrl;
|
|
1078
|
+
if (!url) return null;
|
|
979
1079
|
return `dxtable-perpage-${url.replace(/\//g, '-')}`;
|
|
980
1080
|
});
|
|
981
1081
|
|
|
1082
|
+
// Whether the consumer EXPLICITLY passed `perPage`. A defaulted/omitted prop is
|
|
1083
|
+
// indistinguishable via `props`, so this reads the raw vnode props — the same
|
|
1084
|
+
// technique as `hasListener` above (Vue keeps passed props on the vnode).
|
|
1085
|
+
const wasPerPagePassed = ((): boolean => {
|
|
1086
|
+
const vnodeProps = instance?.vnode.props ?? {};
|
|
1087
|
+
return 'perPage' in vnodeProps || 'per-page' in vnodeProps;
|
|
1088
|
+
})();
|
|
1089
|
+
|
|
982
1090
|
// Load perPage from localStorage or use default
|
|
983
1091
|
const getInitialPerPage = (): number => {
|
|
1092
|
+
// An explicitly-passed `perPage` wins over any stored preference (#124):
|
|
1093
|
+
// `:per-page="20"` means "start at 20" (since #110) and must not be silently
|
|
1094
|
+
// overridden by a size the user once picked on a different table.
|
|
1095
|
+
if (wasPerPagePassed && props.perPage !== undefined) {
|
|
1096
|
+
return props.perPage;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
984
1099
|
if (typeof window === 'undefined') return props.perPage || 10;
|
|
985
1100
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1101
|
+
const storageKey = perPageStorageKey.value;
|
|
1102
|
+
if (storageKey !== null) {
|
|
1103
|
+
try {
|
|
1104
|
+
const saved = localStorage.getItem(storageKey);
|
|
1105
|
+
if (saved !== null) {
|
|
1106
|
+
const parsed = parseInt(saved, 10);
|
|
1107
|
+
// Validate it's in the allowed options
|
|
1108
|
+
if (props.perPageOptions.includes(parsed)) {
|
|
1109
|
+
return parsed;
|
|
1110
|
+
}
|
|
993
1111
|
}
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
console.error('Error loading perPage from localStorage:', error);
|
|
994
1114
|
}
|
|
995
|
-
} catch (error) {
|
|
996
|
-
console.error('Error loading perPage from localStorage:', error);
|
|
997
1115
|
}
|
|
998
1116
|
|
|
999
1117
|
return props.perPage || 10;
|
|
@@ -1006,10 +1124,11 @@ const internalPerPage = ref<number>(getInitialPerPage());
|
|
|
1006
1124
|
watch(() => props.pagination?.per_page, (newPerPage) => {
|
|
1007
1125
|
if (newPerPage && newPerPage !== internalPerPage.value) {
|
|
1008
1126
|
internalPerPage.value = newPerPage;
|
|
1009
|
-
// Also update localStorage to stay in sync
|
|
1010
|
-
|
|
1127
|
+
// Also update localStorage to stay in sync (only for URL-keyed tables).
|
|
1128
|
+
const storageKey = perPageStorageKey.value;
|
|
1129
|
+
if (typeof window !== 'undefined' && storageKey !== null) {
|
|
1011
1130
|
try {
|
|
1012
|
-
localStorage.setItem(
|
|
1131
|
+
localStorage.setItem(storageKey, newPerPage.toString());
|
|
1013
1132
|
} catch (error) {
|
|
1014
1133
|
console.error('Error saving perPage from watcher:', error);
|
|
1015
1134
|
}
|
|
@@ -1445,10 +1564,11 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1445
1564
|
internalPerPage.value = perPageNum;
|
|
1446
1565
|
}
|
|
1447
1566
|
|
|
1448
|
-
// Save to localStorage
|
|
1449
|
-
|
|
1567
|
+
// Save to localStorage (only for URL-keyed tables — see perPageStorageKey).
|
|
1568
|
+
const storageKey = perPageStorageKey.value;
|
|
1569
|
+
if (typeof window !== 'undefined' && storageKey !== null) {
|
|
1450
1570
|
try {
|
|
1451
|
-
localStorage.setItem(
|
|
1571
|
+
localStorage.setItem(storageKey, perPageNum.toString());
|
|
1452
1572
|
} catch (error) {
|
|
1453
1573
|
console.error('Error saving perPage to localStorage:', error);
|
|
1454
1574
|
}
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
from one row into the next. The key gives each record a fresh
|
|
38
38
|
field subtree; the data still lives in `form`, so nothing
|
|
39
39
|
is lost by remounting. -->
|
|
40
|
+
<!-- Tabs stay bare here: the modal body already provides the panel
|
|
41
|
+
boundary, so DXForm's default card-panel-around-tabs (#159) would be
|
|
42
|
+
redundant nested chrome inside the modal. -->
|
|
40
43
|
<DXForm
|
|
41
44
|
v-if="form"
|
|
42
45
|
:key="formInstanceKey"
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
:form="form"
|
|
45
48
|
:fields="fields"
|
|
46
49
|
:tabs="tabs"
|
|
50
|
+
:card-tabs="false"
|
|
47
51
|
:context="item ?? undefined"
|
|
48
52
|
:show-submit="false"
|
|
49
53
|
@update:active-tab="emit('update:activeTab', $event)"
|
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
</div>
|
|
47
47
|
|
|
48
|
-
<!-- Bottom row: Info text
|
|
49
|
-
|
|
48
|
+
<!-- Bottom row: Info text (the item-count caption). Suppressed by
|
|
49
|
+
`showCount` independent of the pager (#127). -->
|
|
50
|
+
<div v-if="showCount" class="small text-muted">
|
|
50
51
|
<div>
|
|
51
52
|
<template v-if="pagination.total > pagination.per_page">
|
|
52
53
|
{{ pagination.from }} to {{ pagination.to }} out of {{ pagination.total }} {{ pagination.total === 1 ? singularItemName : pluralItemName }}.
|
|
@@ -81,6 +82,8 @@ interface Props {
|
|
|
81
82
|
showPagination: boolean;
|
|
82
83
|
/** Whether to render the per-page selector. */
|
|
83
84
|
showPerPageSelector: boolean;
|
|
85
|
+
/** Whether to render the item-count caption (the "X out of Z items" line). */
|
|
86
|
+
showCount: boolean;
|
|
84
87
|
/** Singular item noun for the info line (e.g. "product"). */
|
|
85
88
|
singularItemName: string;
|
|
86
89
|
/** Plural item noun for the info line (e.g. "products"). */
|
|
@@ -154,6 +154,17 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
154
154
|
): UseFormReturn<TData> {
|
|
155
155
|
const snapshot = deepClone(initialData);
|
|
156
156
|
|
|
157
|
+
// Concurrency guards (#133). `useForm` allows overlapping submissions (a
|
|
158
|
+
// consumer can fire two before the first resolves), so:
|
|
159
|
+
// - `inFlightCount` tracks how many requests are active; `processing` only
|
|
160
|
+
// flips false when the LAST one finishes (a per-request `finally` flipping
|
|
161
|
+
// it unconditionally re-enabled the button while another was still running).
|
|
162
|
+
// - `successTimer` is a single handle for the `recentlySuccessful` reset; a
|
|
163
|
+
// new success clears any pending timer before scheduling its own, so an
|
|
164
|
+
// earlier success's timer can't clear the flag a later success just set.
|
|
165
|
+
let inFlightCount = 0;
|
|
166
|
+
let successTimer: ReturnType<typeof setTimeout> | null = null;
|
|
167
|
+
|
|
157
168
|
const state = reactive<FormState<TData>>({
|
|
158
169
|
data: deepClone(snapshot) as TData,
|
|
159
170
|
errors: {},
|
|
@@ -250,6 +261,7 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
250
261
|
url: string,
|
|
251
262
|
options: FormSubmitOptions<TData, TResponse> = {},
|
|
252
263
|
): Promise<TResponse> => {
|
|
264
|
+
inFlightCount += 1;
|
|
253
265
|
state.processing = true;
|
|
254
266
|
if (!options.preserveErrors) clearErrors();
|
|
255
267
|
|
|
@@ -314,7 +326,11 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
314
326
|
});
|
|
315
327
|
|
|
316
328
|
state.recentlySuccessful = true;
|
|
317
|
-
|
|
329
|
+
if (successTimer !== null) clearTimeout(successTimer);
|
|
330
|
+
successTimer = setTimeout(() => {
|
|
331
|
+
state.recentlySuccessful = false;
|
|
332
|
+
successTimer = null;
|
|
333
|
+
}, 1500);
|
|
318
334
|
|
|
319
335
|
options.onSuccess?.(data);
|
|
320
336
|
if (options.resetOnSuccess) {
|
|
@@ -328,7 +344,9 @@ export function useForm<TData extends Record<string, any>>(
|
|
|
328
344
|
options.onError?.(err as FormError);
|
|
329
345
|
throw err;
|
|
330
346
|
} finally {
|
|
331
|
-
|
|
347
|
+
inFlightCount -= 1;
|
|
348
|
+
// Only clear `processing` once no submission is still in flight.
|
|
349
|
+
if (inFlightCount === 0) state.processing = false;
|
|
332
350
|
options.onFinish?.();
|
|
333
351
|
}
|
|
334
352
|
};
|