@omnitend/dashboard-for-laravel 0.25.0 → 0.27.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/assets/bootstrap-icons-6c757103.woff2 +0 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/dashboard-for-laravel.js +7654 -7599
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/style.css +2 -2
- package/docs/public/api-reference.json +38 -25
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +2 -3
- package/package.json +2 -2
- package/resources/css/theme.scss +150 -11
- package/resources/js/components/extended/DXForm.vue +1 -1
- package/resources/js/components/extended/DXTable.vue +345 -42
- package/resources/js/composables/defineForm.ts +14 -2
- package/resources/js/index.ts +14 -1
- package/dist/components/base/DTab.vue.d.ts +0 -12
- package/resources/js/components/base/DTab.vue +0 -14
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
<DTable
|
|
45
45
|
v-else-if="isProviderMode"
|
|
46
46
|
ref="tableRef"
|
|
47
|
+
:key="tableSlotSignature($slots)"
|
|
47
48
|
:provider="effectiveProvider"
|
|
48
49
|
:fields="fields"
|
|
49
50
|
:sort-by="effectiveSortBy"
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
:hover="hover"
|
|
56
57
|
:responsive="responsive"
|
|
57
58
|
:busy="busy"
|
|
59
|
+
:tbody-tr-class="composeRowClass"
|
|
58
60
|
v-bind="tablePassthroughProps"
|
|
59
61
|
@update:sort-by="handleSortChange"
|
|
60
62
|
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
@@ -62,9 +64,19 @@
|
|
|
62
64
|
@update:busy="handleBusyChange"
|
|
63
65
|
@row-clicked="handleRowClick"
|
|
64
66
|
>
|
|
65
|
-
<!--
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
68
|
+
consumer's own thead-top content is COMPOSED above it rather
|
|
69
|
+
than being dropped: a grouped column-header banner or a pinned
|
|
70
|
+
totals row sits above the headers where it belongs (#120). -->
|
|
71
|
+
<template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
|
|
72
|
+
<!--
|
|
73
|
+
@slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
|
|
74
|
+
@binding {object} columns The number of columns in the table.
|
|
75
|
+
@binding {object} fields The table's fields.
|
|
76
|
+
-->
|
|
77
|
+
<slot name="thead-top" v-bind="theadScope" />
|
|
78
|
+
|
|
79
|
+
<tr v-if="hasFilters" class="filter-row">
|
|
68
80
|
<th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
|
|
69
81
|
<!-- Text Filter -->
|
|
70
82
|
<DFormInput
|
|
@@ -125,12 +137,23 @@
|
|
|
125
137
|
</div>
|
|
126
138
|
</template>
|
|
127
139
|
|
|
140
|
+
<!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
|
|
141
|
+
bootstrap-vue-next, in every payload shape, so DXTable resolves
|
|
142
|
+
those columns itself (#121). A consumer's own cell(<key>) slot
|
|
143
|
+
wins — `dottedCellFields` excludes it, and the forwarding below
|
|
144
|
+
would otherwise declare the same slot name twice. -->
|
|
145
|
+
<template
|
|
146
|
+
v-for="field in dottedCellFields($slots)"
|
|
147
|
+
:key="`dotted-cell-${field.key}`"
|
|
148
|
+
#[`cell(${field.key})`]="{ item }"
|
|
149
|
+
>{{ fieldValueOf(item, field.key) }}</template>
|
|
150
|
+
|
|
128
151
|
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
129
152
|
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
130
153
|
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
131
154
|
See `isTableSlot` for the two DXTable renders itself. -->
|
|
132
155
|
<template
|
|
133
|
-
v-for="name in
|
|
156
|
+
v-for="name in forwardableSlotNames($slots)"
|
|
134
157
|
:key="name"
|
|
135
158
|
#[name]="slotProps"
|
|
136
159
|
>
|
|
@@ -144,6 +167,7 @@
|
|
|
144
167
|
<!-- Client-Side Mode: Local filtering, sorting, pagination -->
|
|
145
168
|
<DTable
|
|
146
169
|
v-else-if="isClientSideMode"
|
|
170
|
+
:key="tableSlotSignature($slots)"
|
|
147
171
|
:items="clientSidePaginatedItems"
|
|
148
172
|
:fields="fields"
|
|
149
173
|
:sort-by="effectiveSortBy"
|
|
@@ -153,14 +177,25 @@
|
|
|
153
177
|
:striped="striped"
|
|
154
178
|
:hover="hover"
|
|
155
179
|
:responsive="responsive"
|
|
180
|
+
:tbody-tr-class="composeRowClass"
|
|
156
181
|
v-bind="tablePassthroughProps"
|
|
157
182
|
@update:sort-by="handleSortChange"
|
|
158
183
|
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
159
184
|
@row-clicked="handleRowClick"
|
|
160
185
|
>
|
|
161
|
-
<!--
|
|
162
|
-
|
|
163
|
-
|
|
186
|
+
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
187
|
+
consumer's own thead-top content is COMPOSED above it rather
|
|
188
|
+
than being dropped: a grouped column-header banner or a pinned
|
|
189
|
+
totals row sits above the headers where it belongs (#120). -->
|
|
190
|
+
<template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
|
|
191
|
+
<!--
|
|
192
|
+
@slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
|
|
193
|
+
@binding {object} columns The number of columns in the table.
|
|
194
|
+
@binding {object} fields The table's fields.
|
|
195
|
+
-->
|
|
196
|
+
<slot name="thead-top" v-bind="theadScope" />
|
|
197
|
+
|
|
198
|
+
<tr v-if="hasFilters" class="filter-row">
|
|
164
199
|
<th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
|
|
165
200
|
<!-- Text Filter -->
|
|
166
201
|
<DFormInput
|
|
@@ -221,12 +256,23 @@
|
|
|
221
256
|
</div>
|
|
222
257
|
</template>
|
|
223
258
|
|
|
259
|
+
<!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
|
|
260
|
+
bootstrap-vue-next, in every payload shape, so DXTable resolves
|
|
261
|
+
those columns itself (#121). A consumer's own cell(<key>) slot
|
|
262
|
+
wins — `dottedCellFields` excludes it, and the forwarding below
|
|
263
|
+
would otherwise declare the same slot name twice. -->
|
|
264
|
+
<template
|
|
265
|
+
v-for="field in dottedCellFields($slots)"
|
|
266
|
+
:key="`dotted-cell-${field.key}`"
|
|
267
|
+
#[`cell(${field.key})`]="{ item }"
|
|
268
|
+
>{{ fieldValueOf(item, field.key) }}</template>
|
|
269
|
+
|
|
224
270
|
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
225
271
|
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
226
272
|
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
227
273
|
See `isTableSlot` for the two DXTable renders itself. -->
|
|
228
274
|
<template
|
|
229
|
-
v-for="name in
|
|
275
|
+
v-for="name in forwardableSlotNames($slots)"
|
|
230
276
|
:key="name"
|
|
231
277
|
#[name]="slotProps"
|
|
232
278
|
>
|
|
@@ -240,6 +286,7 @@
|
|
|
240
286
|
<!-- Inertia Mode: Use items prop -->
|
|
241
287
|
<DTable
|
|
242
288
|
v-else-if="isInertiaMode"
|
|
289
|
+
:key="tableSlotSignature($slots)"
|
|
243
290
|
:items="items"
|
|
244
291
|
:fields="fields"
|
|
245
292
|
:sort-by="effectiveSortBy"
|
|
@@ -250,14 +297,25 @@
|
|
|
250
297
|
:hover="hover"
|
|
251
298
|
:responsive="responsive"
|
|
252
299
|
:busy="effectiveBusy"
|
|
300
|
+
:tbody-tr-class="composeRowClass"
|
|
253
301
|
v-bind="tablePassthroughProps"
|
|
254
302
|
@update:sort-by="handleSortChange"
|
|
255
303
|
@update:expanded-items="emit('update:expandedItems', $event)"
|
|
256
304
|
@row-clicked="handleRowClick"
|
|
257
305
|
>
|
|
258
|
-
<!--
|
|
259
|
-
|
|
260
|
-
|
|
306
|
+
<!-- DXTable owns `thead-top` (its filter row lives there), so a
|
|
307
|
+
consumer's own thead-top content is COMPOSED above it rather
|
|
308
|
+
than being dropped: a grouped column-header banner or a pinned
|
|
309
|
+
totals row sits above the headers where it belongs (#120). -->
|
|
310
|
+
<template v-if="hasFilters || $slots['thead-top']" #thead-top="theadScope">
|
|
311
|
+
<!--
|
|
312
|
+
@slot A row rendered ABOVE the column headers — a grouped-column banner (a `<th colspan>` spanning several columns), or a pinned totals row. Renders above DXTable's own filter row. Give it `<tr>`s.
|
|
313
|
+
@binding {object} columns The number of columns in the table.
|
|
314
|
+
@binding {object} fields The table's fields.
|
|
315
|
+
-->
|
|
316
|
+
<slot name="thead-top" v-bind="theadScope" />
|
|
317
|
+
|
|
318
|
+
<tr v-if="hasFilters" class="filter-row">
|
|
261
319
|
<th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
|
|
262
320
|
<!-- Text Filter -->
|
|
263
321
|
<DFormInput
|
|
@@ -318,12 +376,23 @@
|
|
|
318
376
|
</div>
|
|
319
377
|
</template>
|
|
320
378
|
|
|
379
|
+
<!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
|
|
380
|
+
bootstrap-vue-next, in every payload shape, so DXTable resolves
|
|
381
|
+
those columns itself (#121). A consumer's own cell(<key>) slot
|
|
382
|
+
wins — `dottedCellFields` excludes it, and the forwarding below
|
|
383
|
+
would otherwise declare the same slot name twice. -->
|
|
384
|
+
<template
|
|
385
|
+
v-for="field in dottedCellFields($slots)"
|
|
386
|
+
:key="`dotted-cell-${field.key}`"
|
|
387
|
+
#[`cell(${field.key})`]="{ item }"
|
|
388
|
+
>{{ fieldValueOf(item, field.key) }}</template>
|
|
389
|
+
|
|
321
390
|
<!-- Forward every slot the inner table understands: cell(<key>),
|
|
322
391
|
foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
|
|
323
392
|
table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
|
|
324
393
|
See `isTableSlot` for the two DXTable renders itself. -->
|
|
325
394
|
<template
|
|
326
|
-
v-for="name in
|
|
395
|
+
v-for="name in forwardableSlotNames($slots)"
|
|
327
396
|
:key="name"
|
|
328
397
|
#[name]="slotProps"
|
|
329
398
|
>
|
|
@@ -641,6 +710,7 @@ import axios from "axios";
|
|
|
641
710
|
import pluralize from "pluralize";
|
|
642
711
|
import { useToast } from "../../composables/useToast";
|
|
643
712
|
import { useForm } from "../../composables/useForm";
|
|
713
|
+
import { getByPath } from "../../utils/objectPath";
|
|
644
714
|
import DContainer from "../base/DContainer.vue";
|
|
645
715
|
import DRow from "../base/DRow.vue";
|
|
646
716
|
import DCol from "../base/DCol.vue";
|
|
@@ -917,6 +987,22 @@ export interface Props<TItem = any> {
|
|
|
917
987
|
*/
|
|
918
988
|
expandedItems?: TItem[];
|
|
919
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Class(es) applied to each row's `<tr>` — a string, or a function of the
|
|
992
|
+
* row. Use for conditional row styling (a variant, a muted/disabled look)
|
|
993
|
+
* without reaching into the table's internal DOM from global CSS.
|
|
994
|
+
*/
|
|
995
|
+
rowClass?: string | ((item: TItem, index: number) => string | string[] | Record<string, boolean>);
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Whether a given row is actionable. Only relevant when rows are clickable
|
|
999
|
+
* (an `editFields` modal or a `row-clicked` listener). A row this returns
|
|
1000
|
+
* `false` for gets no pointer cursor, no hover highlight, and does not fire
|
|
1001
|
+
* `row-clicked` or open the edit modal — so a row that isn't clickable
|
|
1002
|
+
* doesn't *look* clickable, and a click on it can't quietly do something.
|
|
1003
|
+
*/
|
|
1004
|
+
rowClickable?: (item: TItem, index: number) => boolean;
|
|
1005
|
+
|
|
920
1006
|
/** Enable client-side filtering, sorting, and pagination on items array */
|
|
921
1007
|
clientSide?: boolean;
|
|
922
1008
|
}
|
|
@@ -1045,10 +1131,30 @@ const rowsAreInteractive = computed(
|
|
|
1045
1131
|
() => (props.editFields?.length ?? 0) > 0 || hasListener('rowClicked'),
|
|
1046
1132
|
);
|
|
1047
1133
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
)
|
|
1134
|
+
/**
|
|
1135
|
+
* A row is actionable when the table is interactive AND the consumer's
|
|
1136
|
+
* `rowClickable` predicate (if any) says so — a row-clicked table can have rows
|
|
1137
|
+
* that aren't navigable (#115).
|
|
1138
|
+
*/
|
|
1139
|
+
const isRowActionable = (item: T, index: number): boolean =>
|
|
1140
|
+
rowsAreInteractive.value && (props.rowClickable?.(item, index) ?? true);
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* The `<tr>` class, composed from the consumer's `rowClass` and our own
|
|
1144
|
+
* actionable marker. The pointer/hover affordance hangs off the marker class
|
|
1145
|
+
* rather than a blanket `tbody tr` rule, so a non-actionable row simply doesn't
|
|
1146
|
+
* get it — which is what let consumers drop their global
|
|
1147
|
+
* `tbody tr:has(.marker)` CSS hack.
|
|
1148
|
+
*/
|
|
1149
|
+
const composeRowClass = (item: T, index: number) => {
|
|
1150
|
+
const consumerClass =
|
|
1151
|
+
typeof props.rowClass === 'function' ? props.rowClass(item, index) : props.rowClass;
|
|
1152
|
+
|
|
1153
|
+
const classes: any[] = [];
|
|
1154
|
+
if (consumerClass) classes.push(consumerClass);
|
|
1155
|
+
if (isRowActionable(item, index)) classes.push('dx-row-actionable');
|
|
1156
|
+
return classes;
|
|
1157
|
+
};
|
|
1052
1158
|
|
|
1053
1159
|
// Internal sortBy state, seeded from the prop when it's an initial value.
|
|
1054
1160
|
const internalSortBy = ref<BTableSortBy[]>(props.sortBy ? [...props.sortBy] : []);
|
|
@@ -1150,10 +1256,11 @@ const clientSideFilteredItems = computed<T[]>(() => {
|
|
|
1150
1256
|
// `customer_id`). Match on the filter key when the row has it,
|
|
1151
1257
|
// otherwise fall back to the column's own value — which is also the
|
|
1152
1258
|
// one the user can actually see and type against.
|
|
1259
|
+
// Both lookups are dot-path aware, so a nested payload filters (#121).
|
|
1153
1260
|
const itemValue =
|
|
1154
|
-
field &&
|
|
1155
|
-
? (item
|
|
1156
|
-
: (item
|
|
1261
|
+
field && hasFieldValue(item, key)
|
|
1262
|
+
? fieldValueOf(item, key)
|
|
1263
|
+
: fieldValueOf(item, field?.key ?? key);
|
|
1157
1264
|
|
|
1158
1265
|
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
1159
1266
|
// MATCHES an absent value rather than failing on it.
|
|
@@ -1207,13 +1314,16 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1207
1314
|
const direction = order === 'desc' ? -1 : 1;
|
|
1208
1315
|
|
|
1209
1316
|
return items.sort((a, b) => {
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1317
|
+
// Dot-path aware, so `paid_by.card` sorts a nested payload (#121).
|
|
1318
|
+
const aVal = fieldValueOf(a, key);
|
|
1319
|
+
const bVal = fieldValueOf(b, key);
|
|
1212
1320
|
|
|
1213
1321
|
// Handle null/undefined
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
if (
|
|
1322
|
+
const aEmpty = aVal === null || aVal === undefined;
|
|
1323
|
+
const bEmpty = bVal === null || bVal === undefined;
|
|
1324
|
+
if (aEmpty && bEmpty) return 0;
|
|
1325
|
+
if (aEmpty) return direction;
|
|
1326
|
+
if (bEmpty) return -direction;
|
|
1217
1327
|
|
|
1218
1328
|
// Numeric comparison
|
|
1219
1329
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
@@ -1228,6 +1338,54 @@ const clientSideSortedItems = computed<T[]>(() => {
|
|
|
1228
1338
|
// API mode filter values (extracted from responses)
|
|
1229
1339
|
const apiFilterValues = ref<Record<string, string[]>>({});
|
|
1230
1340
|
|
|
1341
|
+
/*
|
|
1342
|
+
* Resolving a field's value from a row, dot-path aware (#121).
|
|
1343
|
+
*
|
|
1344
|
+
* A key containing a dot (`paid_by.card`) was unusable. Two layers disagreed:
|
|
1345
|
+
* bootstrap-vue-next's `mapItem` UN-flattens any item key containing a dot
|
|
1346
|
+
* (`{'paid_by.card': v}` becomes `{paid_by: {card: v}}`) and then its cell reads
|
|
1347
|
+
* `item['paid_by.card']` FLAT and gets undefined — so bvn renders a dotted key
|
|
1348
|
+
* as an empty cell in BOTH payload shapes. Meanwhile our own client-side sort
|
|
1349
|
+
* and filter also read flat, which a NESTED payload (what Laravel serialises for
|
|
1350
|
+
* a relation) never satisfies. Consumers worked around it by flattening every
|
|
1351
|
+
* nested key to an `_x` alias in each client-side port.
|
|
1352
|
+
*
|
|
1353
|
+
* LITERAL key first, then dot-path. That single order covers every shape:
|
|
1354
|
+
* an ordinary key (`name`), a dots-flat payload (the literal `paid_by.card`
|
|
1355
|
+
* key), and a nested payload (walk `paid_by` -> `card`). It also means bvn's
|
|
1356
|
+
* un-flattening is harmless rather than hostile — mapItem normalises flat to
|
|
1357
|
+
* nested, and the path read finds it either way.
|
|
1358
|
+
*/
|
|
1359
|
+
const fieldValueOf = (item: unknown, key: string): any => {
|
|
1360
|
+
if (item === null || item === undefined) return undefined;
|
|
1361
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
1362
|
+
return (item as Record<string, any>)[key];
|
|
1363
|
+
}
|
|
1364
|
+
return getByPath(item as Record<string, any>, key);
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
/*
|
|
1368
|
+
* Whether a row carries a value for `key` at all — presence, not truthiness.
|
|
1369
|
+
* Mirrors the `key in item` check this replaced, extended to dot paths.
|
|
1370
|
+
*/
|
|
1371
|
+
const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
1372
|
+
if (item === null || item === undefined) return false;
|
|
1373
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) return true;
|
|
1374
|
+
return getByPath(item as Record<string, any>, key) !== undefined;
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
/*
|
|
1378
|
+
* Dotted-key columns DXTable must render itself, because bvn renders them empty
|
|
1379
|
+
* (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
|
|
1380
|
+
* forwarded separately, and declaring the same slot name twice would clobber it.
|
|
1381
|
+
*
|
|
1382
|
+
* A FUNCTION, not a computed, for the same reason as `forwardableSlotNames`:
|
|
1383
|
+
* `useSlots()` returns an object Vue mutates in place with nothing to track, so
|
|
1384
|
+
* a computed over its keys is captured on first render and never updates (#114).
|
|
1385
|
+
*/
|
|
1386
|
+
const dottedCellFields = (slots: Record<string, unknown>): TableField[] =>
|
|
1387
|
+
props.fields.filter((field) => field.key.includes('.') && !slots[`cell(${field.key})`]);
|
|
1388
|
+
|
|
1231
1389
|
// Computed: Get effective filter options for a field
|
|
1232
1390
|
// The key a column's filter is SENT under, which is not always the column's own
|
|
1233
1391
|
// key: a display column can filter on a different server param via `filterKey`.
|
|
@@ -1414,12 +1572,37 @@ const effectivePerPage = computed(() => {
|
|
|
1414
1572
|
// Client-Side Mode: Pagination (requires effectivePerPage)
|
|
1415
1573
|
// ============================================
|
|
1416
1574
|
|
|
1575
|
+
/*
|
|
1576
|
+
* The page the client-side view actually renders (#118).
|
|
1577
|
+
*
|
|
1578
|
+
* `clientSideCurrentPage` is only reset on a filter or per-page change, so when
|
|
1579
|
+
* the `items` prop SHRINKS — a consumer refetching a narrower dataset — it can
|
|
1580
|
+
* point past the end of the new set. The pagination metadata below already
|
|
1581
|
+
* clamped for display, but the slice used the raw value, so the pager said
|
|
1582
|
+
* "page 2 of 2" while the table sliced page 6 and rendered zero rows. Both read
|
|
1583
|
+
* from this now, so the view and its pager can't disagree.
|
|
1584
|
+
*/
|
|
1585
|
+
const clientSideLastPage = computed(() =>
|
|
1586
|
+
Math.max(1, Math.ceil(clientSideFilteredItems.value.length / effectivePerPage.value)),
|
|
1587
|
+
);
|
|
1588
|
+
|
|
1589
|
+
const clientSidePage = computed(() =>
|
|
1590
|
+
Math.min(Math.max(1, clientSideCurrentPage.value), clientSideLastPage.value),
|
|
1591
|
+
);
|
|
1592
|
+
|
|
1593
|
+
// Write the clamp back, so the NEXT interaction starts from a real page rather
|
|
1594
|
+
// than a stale one. Clamping to the last page (not resetting to 1) keeps the
|
|
1595
|
+
// user near where they were.
|
|
1596
|
+
watch(clientSideLastPage, (lastPage) => {
|
|
1597
|
+
if (clientSideCurrentPage.value > lastPage) clientSideCurrentPage.value = lastPage;
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1417
1600
|
// Client-side paginated items (final output)
|
|
1418
1601
|
const clientSidePaginatedItems = computed<T[]>(() => {
|
|
1419
1602
|
if (!isClientSideMode.value) return [];
|
|
1420
1603
|
|
|
1421
1604
|
const perPage = effectivePerPage.value;
|
|
1422
|
-
const start = (
|
|
1605
|
+
const start = (clientSidePage.value - 1) * perPage;
|
|
1423
1606
|
const end = start + perPage;
|
|
1424
1607
|
|
|
1425
1608
|
return clientSideSortedItems.value.slice(start, end);
|
|
@@ -1430,11 +1613,10 @@ const clientSidePagination = computed<PaginationData>(() => {
|
|
|
1430
1613
|
const total = clientSideFilteredItems.value.length;
|
|
1431
1614
|
const totalUnfiltered = props.items?.length || 0;
|
|
1432
1615
|
const perPage = effectivePerPage.value;
|
|
1433
|
-
const
|
|
1434
|
-
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
1616
|
+
const lastPage = clientSideLastPage.value;
|
|
1435
1617
|
|
|
1436
|
-
//
|
|
1437
|
-
const validPage =
|
|
1618
|
+
// Same clamped page the slice uses — see `clientSidePage` (#118).
|
|
1619
|
+
const validPage = clientSidePage.value;
|
|
1438
1620
|
|
|
1439
1621
|
const from = total > 0 ? (validPage - 1) * perPage + 1 : 0;
|
|
1440
1622
|
const to = Math.min(validPage * perPage, total);
|
|
@@ -1759,6 +1941,7 @@ const handleFilterChange = (fieldKey: string, value: string) => {
|
|
|
1759
1941
|
{
|
|
1760
1942
|
page: 1, // Reset to first page when filtering
|
|
1761
1943
|
filters: newFilters,
|
|
1944
|
+
perPage: effectivePerPage.value,
|
|
1762
1945
|
...sortParams(effectiveSortBy.value),
|
|
1763
1946
|
},
|
|
1764
1947
|
{ preserveState: true }
|
|
@@ -1833,6 +2016,55 @@ try {
|
|
|
1833
2016
|
// Forward only the keyed edit slots the consumer actually provided, so
|
|
1834
2017
|
// DXForm doesn't mistake an always-present (but empty) wrapper for
|
|
1835
2018
|
// a real custom-value override.
|
|
2019
|
+
/*
|
|
2020
|
+
* A field's own starting value, used to seed a CREATE form and as the fallback
|
|
2021
|
+
* when a row lacks the key (#122).
|
|
2022
|
+
*
|
|
2023
|
+
* `field.default ?? ''` can't express a NULL default: a select whose "none"
|
|
2024
|
+
* option is `value: null` — which is what the column stores — seeds '' instead,
|
|
2025
|
+
* matches no option, and renders blank. Presence, not nullishness, decides here
|
|
2026
|
+
* too, so the create and edit paths agree about the same field.
|
|
2027
|
+
*/
|
|
2028
|
+
const defaultValueFor = (field: any): any =>
|
|
2029
|
+
Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
|
|
2030
|
+
|
|
2031
|
+
/*
|
|
2032
|
+
* Seeding an EDIT form from a row (#117).
|
|
2033
|
+
*
|
|
2034
|
+
* `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
|
|
2035
|
+
* from "the row's value IS null", so an explicitly-null column gets overwritten
|
|
2036
|
+
* by the field's default — a value the user never saw, on a field they may not
|
|
2037
|
+
* even be able to see. Presence, not nullishness, decides.
|
|
2038
|
+
*/
|
|
2039
|
+
const seedValueFor = (field: any, row: T): any => {
|
|
2040
|
+
if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
|
|
2041
|
+
return (row as any)[field.key];
|
|
2042
|
+
}
|
|
2043
|
+
return defaultValueFor(field);
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
/*
|
|
2047
|
+
* Whether a field is currently VISIBLE, by the same rule DXForm renders by
|
|
2048
|
+
* (`when` + the legacy `show`), against the same model (context + live data).
|
|
2049
|
+
* Kept in step with DXForm.isFieldVisible.
|
|
2050
|
+
*/
|
|
2051
|
+
const editPredicateModel = computed(() => ({
|
|
2052
|
+
...((selectedItem.value as any) ?? {}),
|
|
2053
|
+
...(editForm.value?.data ?? {}),
|
|
2054
|
+
}));
|
|
2055
|
+
|
|
2056
|
+
const isEditFieldVisible = (field: any): boolean => {
|
|
2057
|
+
const when = field.when;
|
|
2058
|
+
const whenOk =
|
|
2059
|
+
when === undefined
|
|
2060
|
+
? true
|
|
2061
|
+
: typeof when === 'function'
|
|
2062
|
+
? when(editPredicateModel.value)
|
|
2063
|
+
: when;
|
|
2064
|
+
const showOk = field.show ? field.show() : true;
|
|
2065
|
+
return whenOk && showOk;
|
|
2066
|
+
};
|
|
2067
|
+
|
|
1836
2068
|
// Fields whose value actually belongs in the payload. A presentational field
|
|
1837
2069
|
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
1838
2070
|
const submittableEditFields = computed(() =>
|
|
@@ -1843,7 +2075,21 @@ const submittableEditFields = computed(() =>
|
|
|
1843
2075
|
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
1844
2076
|
// its key back into the form data after seeding. Strip them on the way out.
|
|
1845
2077
|
const nonSubmittedFieldKeys = computed(
|
|
1846
|
-
() =>
|
|
2078
|
+
() =>
|
|
2079
|
+
new Set(
|
|
2080
|
+
(props.editFields ?? [])
|
|
2081
|
+
.filter(
|
|
2082
|
+
(field) =>
|
|
2083
|
+
// Presentational: lays the form out, holds no data (#110).
|
|
2084
|
+
field.submit === false ||
|
|
2085
|
+
// Hidden by `when` at submit time (#117). Submitting a field
|
|
2086
|
+
// the user cannot see is a silent write — and with `default`
|
|
2087
|
+
// set it writes a value they never chose. Omitting the key
|
|
2088
|
+
// leaves the stored value alone.
|
|
2089
|
+
!isEditFieldVisible(field),
|
|
2090
|
+
)
|
|
2091
|
+
.map((field) => field.key),
|
|
2092
|
+
),
|
|
1847
2093
|
);
|
|
1848
2094
|
|
|
1849
2095
|
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
@@ -1885,9 +2131,59 @@ const TABLE_SLOT_NAMES = new Set([
|
|
|
1885
2131
|
const isTableSlot = (name: string) =>
|
|
1886
2132
|
TABLE_SLOT_NAMES.has(name) || TABLE_SLOT_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
1887
2133
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2134
|
+
/*
|
|
2135
|
+
* The inner table is KEYED on the set of forwarded slot names (#114).
|
|
2136
|
+
*
|
|
2137
|
+
* bootstrap-vue-next's BTable captures its slot set at mount: a `cell(...)` slot
|
|
2138
|
+
* that comes into existence LATER — a consumer whose columns are data-driven and
|
|
2139
|
+
* only known once a fetch resolves — never reaches the cells, and the column
|
|
2140
|
+
* renders raw values as though no cell slot existed. Verified against raw BTable,
|
|
2141
|
+
* so it isn't something our forwarding chain drops.
|
|
2142
|
+
*
|
|
2143
|
+
* Nothing in the forwarding can fix that, so the inner table remounts when the
|
|
2144
|
+
* slot SET changes. Consumers were already remounting to work around it, but at
|
|
2145
|
+
* the DXTable level, which throws away per-page, filters, sort and page — all of
|
|
2146
|
+
* which live out here and now survive. The signature is names-only, so a slot's
|
|
2147
|
+
* CONTENT changing (the common case) doesn't remount anything.
|
|
2148
|
+
*
|
|
2149
|
+
* In provider mode a remount refetches; that only happens when the column set
|
|
2150
|
+
* itself changes, which is already a refetch in practice.
|
|
2151
|
+
*/
|
|
2152
|
+
/*
|
|
2153
|
+
* A FUNCTION, not a computed: `useSlots()` returns an object Vue mutates in
|
|
2154
|
+
* place with nothing to track, so a computed over its keys is captured on the
|
|
2155
|
+
* first render and never updates — which is what made late `cell(...)` slots
|
|
2156
|
+
* invisible (#114). Called from the template, it re-evaluates every render.
|
|
2157
|
+
*
|
|
2158
|
+
* It also has to EXCLUDE the slots DXTable renders itself: declaring `#thead-top`
|
|
2159
|
+
* here (even rendering nothing into it) would override DXTable's own thead-top
|
|
2160
|
+
* template, silently dropping the filter row and the consumer's banner (#120).
|
|
2161
|
+
*/
|
|
2162
|
+
const forwardableSlotNames = (slots: Record<string, unknown>): string[] =>
|
|
2163
|
+
Object.keys(slots).filter((name) => isTableSlot(name));
|
|
2164
|
+
|
|
2165
|
+
const tableSlotSignature = (slots: Record<string, unknown>): string => {
|
|
2166
|
+
// DXTable's OWN dotted-cell slots count too (#121). bvn captures its cell
|
|
2167
|
+
// slot set at mount (#114), so a dotted-key column that appears LATER —
|
|
2168
|
+
// data-driven columns resolved after a fetch — adds a `cell(...)` slot bvn
|
|
2169
|
+
// ignores, and the cell renders empty. Folding the dotted-cell field keys
|
|
2170
|
+
// into the signature remounts the inner table when that set changes, exactly
|
|
2171
|
+
// as it does for a consumer's late `cell(...)` slot.
|
|
2172
|
+
const consumerSlots = Object.keys(slots).filter(isTableSlot);
|
|
2173
|
+
const dottedCellSlots = dottedCellFields(slots).map((field) => `cell(${field.key})`);
|
|
2174
|
+
return [...consumerSlots, ...dottedCellSlots].sort().join('|');
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
/*
|
|
2178
|
+
* The template iterates `$slots` DIRECTLY rather than a computed over
|
|
2179
|
+
* `useSlots()`. It has to: `useSlots()` returns an object that Vue MUTATES in
|
|
2180
|
+
* place, with no reactive dependency to track — so a computed over its keys is
|
|
2181
|
+
* captured on first render and never recomputes. A consumer whose `cell(...)`
|
|
2182
|
+
* slots come into existence after a fetch resolves (data-driven columns) got
|
|
2183
|
+
* columns with raw values and no cell templates, and had to remount the whole
|
|
2184
|
+
* table to fix it (#114). Reading `$slots` inside the render function re-reads
|
|
2185
|
+
* the current slot set on every update.
|
|
2186
|
+
*/
|
|
1891
2187
|
|
|
1892
2188
|
// Table-level features of the inner BTable, exposed verbatim and bound to all
|
|
1893
2189
|
// three data modes from one place.
|
|
@@ -1956,6 +2252,11 @@ const getFieldSortState = (fieldKey: string) => {
|
|
|
1956
2252
|
|
|
1957
2253
|
// Handle row click for editing
|
|
1958
2254
|
const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
2255
|
+
// A row the consumer marked non-actionable doesn't look clickable, so it
|
|
2256
|
+
// must not BE clickable either — otherwise a click that looks dead quietly
|
|
2257
|
+
// navigates or opens the modal (#115).
|
|
2258
|
+
if (props.rowClickable && !props.rowClickable(item, index)) return;
|
|
2259
|
+
|
|
1959
2260
|
// Always emit rowClicked for custom handling
|
|
1960
2261
|
emit('rowClicked', item, index, event);
|
|
1961
2262
|
|
|
@@ -1975,13 +2276,13 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1975
2276
|
// Presentational fields (submit: false) lay the form out; they hold
|
|
1976
2277
|
// no data and must not be POSTed just because they were declared.
|
|
1977
2278
|
submittableEditFields.value.forEach(field => {
|
|
1978
|
-
formData[field.key] = (item
|
|
2279
|
+
formData[field.key] = seedValueFor(field, item);
|
|
1979
2280
|
});
|
|
1980
2281
|
editForm.value = useForm(formData);
|
|
1981
2282
|
} else {
|
|
1982
2283
|
// Update existing form
|
|
1983
2284
|
submittableEditFields.value.forEach(field => {
|
|
1984
|
-
editForm.value.data[field.key] = (item
|
|
2285
|
+
editForm.value.data[field.key] = seedValueFor(field, item);
|
|
1985
2286
|
});
|
|
1986
2287
|
editForm.value.clearErrors();
|
|
1987
2288
|
}
|
|
@@ -2052,13 +2353,13 @@ const handleCreateNew = () => {
|
|
|
2052
2353
|
if (!editForm.value) {
|
|
2053
2354
|
const formData: Record<string, any> = {};
|
|
2054
2355
|
submittableEditFields.value.forEach(field => {
|
|
2055
|
-
formData[field.key] = field
|
|
2356
|
+
formData[field.key] = defaultValueFor(field);
|
|
2056
2357
|
});
|
|
2057
2358
|
editForm.value = useForm(formData);
|
|
2058
2359
|
} else {
|
|
2059
2360
|
// Reset existing form to defaults
|
|
2060
2361
|
submittableEditFields.value.forEach(field => {
|
|
2061
|
-
editForm.value.data[field.key] = field
|
|
2362
|
+
editForm.value.data[field.key] = defaultValueFor(field);
|
|
2062
2363
|
});
|
|
2063
2364
|
editForm.value.clearErrors();
|
|
2064
2365
|
}
|
|
@@ -2284,13 +2585,15 @@ defineExpose({
|
|
|
2284
2585
|
</script>
|
|
2285
2586
|
|
|
2286
2587
|
<style scoped>
|
|
2287
|
-
/* Rows that do something on click look like it
|
|
2288
|
-
|
|
2289
|
-
|
|
2588
|
+
/* Rows that do something on click look like it. Hung off a marker class rather
|
|
2589
|
+
than a blanket `tbody tr` rule, so a row the consumer's `rowClickable` says is
|
|
2590
|
+
not actionable simply doesn't get the affordance (#107, #115). */
|
|
2591
|
+
:deep(tbody tr.dx-row-actionable) {
|
|
2592
|
+
cursor: pointer;
|
|
2290
2593
|
}
|
|
2291
2594
|
|
|
2292
|
-
:deep(tbody tr:hover) {
|
|
2293
|
-
background-color:
|
|
2595
|
+
:deep(tbody tr.dx-row-actionable:hover) {
|
|
2596
|
+
background-color: var(--bs-table-hover-bg);
|
|
2294
2597
|
}
|
|
2295
2598
|
|
|
2296
2599
|
/* Improve pagination button sizing to match form controls */
|
|
@@ -65,10 +65,22 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
|
|
|
65
65
|
): DefineFormReturn<InferFormData<TFields>> {
|
|
66
66
|
type FormData = InferFormData<TFields>;
|
|
67
67
|
|
|
68
|
-
// Extract initial data from field definitions
|
|
68
|
+
// Extract initial data from field definitions.
|
|
69
|
+
//
|
|
70
|
+
// #125: `field.default ?? getDefaultValueForType(type)` cannot express a
|
|
71
|
+
// *null* default — a select whose "none" option is `value: null` (which is
|
|
72
|
+
// what the column stores) seeded '' (the type default), matched no option,
|
|
73
|
+
// and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
|
|
74
|
+
// definedness, not nullishness, so null survives; an absent (or explicitly
|
|
75
|
+
// `undefined`) default still falls back to the type default rather than
|
|
76
|
+
// seeding `undefined`, which would drop the key from the JSON payload. This
|
|
77
|
+
// matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
|
|
69
78
|
const initialData = fieldDefinitions.reduce(
|
|
70
79
|
(acc, field) => {
|
|
71
|
-
acc[field.key] =
|
|
80
|
+
acc[field.key] =
|
|
81
|
+
field.default !== undefined
|
|
82
|
+
? field.default
|
|
83
|
+
: getDefaultValueForType(field.type);
|
|
72
84
|
return acc;
|
|
73
85
|
},
|
|
74
86
|
{} as Record<string, any>,
|
package/resources/js/index.ts
CHANGED
|
@@ -85,7 +85,20 @@ export { default as DProgress } from "./components/base/DProgress.vue";
|
|
|
85
85
|
export { default as DRow } from "./components/base/DRow.vue";
|
|
86
86
|
export { default as DSpinner } from "./components/base/DSpinner.vue";
|
|
87
87
|
export { default as DTable } from "./components/base/DTable.vue";
|
|
88
|
-
|
|
88
|
+
/*
|
|
89
|
+
* DTab is a RAW RE-EXPORT of BTab, not a wrapper — same exception as
|
|
90
|
+
* DCarouselSlide above, and for the same reason.
|
|
91
|
+
*
|
|
92
|
+
* BTabs decides which tab to activate on mount by scanning its slot vnodes for
|
|
93
|
+
* the BTab component type (its initial-selection branch is gated on
|
|
94
|
+
* `tabElementsArray.length > 0`). A wrapper component in between hides that, so
|
|
95
|
+
* BTabs found no tabs to select and NO pane got `active`/`show`: the nav
|
|
96
|
+
* rendered and every body was invisible until the user clicked (#119).
|
|
97
|
+
*
|
|
98
|
+
* Bisected — BTabs+BTab and DTabs+BTab both activate the first tab; BTabs+DTab
|
|
99
|
+
* does not. So it is the child wrapper, and only the child, that has to go.
|
|
100
|
+
*/
|
|
101
|
+
export { BTab as DTab } from "bootstrap-vue-next";
|
|
89
102
|
export { default as DTabs } from "./components/base/DTabs.vue";
|
|
90
103
|
export { default as DToast } from "./components/base/DToast.vue";
|
|
91
104
|
export { default as DToaster } from "./components/base/DToaster.vue";
|