@omnitend/dashboard-for-laravel 0.33.1 → 0.35.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 +82 -90
- 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/base/DButton.vue.d.ts +1 -1
- package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
- package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
- package/dist/components/charts/useThemedChart.d.ts +35 -0
- package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
- package/dist/components/extended/DXField.vue.d.ts +10 -113
- package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
- package/dist/components/extended/DXForm.vue.d.ts +28 -6
- package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
- package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
- package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
- package/dist/components/extended/DXTable.vue.d.ts +107 -2
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +22 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
- package/dist/composables/useAsyncOptions.d.ts +20 -0
- package/dist/composables/useContainerWidth.d.ts +96 -0
- package/dist/composables/useResourceEditor.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +10513 -10443
- 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 +5 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +146 -0
- package/dist/utils/formSchema.d.ts +59 -0
- package/docs/public/api-reference.json +494 -33
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +5 -2
- package/package.json +1 -1
- package/resources/css/theme.scss +80 -2
- package/resources/js/components/charts/DXBarChart.vue +4 -43
- package/resources/js/components/charts/DXLineChart.vue +4 -43
- package/resources/js/components/charts/useThemedChart.ts +69 -0
- package/resources/js/components/extended/DXChoiceField.vue +114 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +1 -1
- package/resources/js/components/extended/DXField.vue +328 -615
- package/resources/js/components/extended/DXFieldShell.vue +124 -0
- package/resources/js/components/extended/DXForm.vue +92 -14
- package/resources/js/components/extended/DXNumericField.vue +144 -0
- package/resources/js/components/extended/DXRepeater.vue +53 -175
- package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
- package/resources/js/components/extended/DXTable.vue +418 -43
- package/resources/js/components/extended/DXTableEditorModal.vue +39 -2
- package/resources/js/components/extended/DXTablePagination.vue +19 -3
- package/resources/js/components/extended/DXTableShell.vue +70 -1
- package/resources/js/composables/defineForm.ts +8 -44
- package/resources/js/composables/useAsyncOptions.ts +61 -0
- package/resources/js/composables/useContainerWidth.ts +303 -0
- package/resources/js/composables/useResourceEditor.ts +24 -38
- package/resources/js/index.ts +40 -0
- package/resources/js/types/index.ts +231 -0
- package/resources/js/utils/formSchema.ts +126 -0
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
request is usually caused by the sort or a filter, and
|
|
32
32
|
replacing the table would take away the very controls
|
|
33
33
|
needed to undo it, leaving a reload as the only way out. -->
|
|
34
|
-
<div v-if="error || apiError" class="alert alert-danger">
|
|
34
|
+
<div v-if="error || (apiError && isProviderMode)" class="alert alert-danger">
|
|
35
35
|
{{ error || apiError }}
|
|
36
36
|
</div>
|
|
37
37
|
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<DTable
|
|
50
50
|
v-else-if="isProviderMode || isClientSideMode || isInertiaMode"
|
|
51
51
|
ref="tableRef"
|
|
52
|
-
:key="tableSlotSignature($slots)"
|
|
52
|
+
:key="activeMode + ':' + tableSlotSignature($slots)"
|
|
53
53
|
:fields="fields"
|
|
54
54
|
:sort-by="effectiveSortBy"
|
|
55
55
|
:multisort="false"
|
|
@@ -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>
|
|
@@ -239,6 +268,13 @@
|
|
|
239
268
|
:is-create-mode="isCreateMode"
|
|
240
269
|
:pending-action="pendingAction"
|
|
241
270
|
:delete-url="deleteUrl"
|
|
271
|
+
:layout="editLayout"
|
|
272
|
+
:label-cols="editLabelCols"
|
|
273
|
+
:card="editCard"
|
|
274
|
+
:item-name="itemName"
|
|
275
|
+
:save-text="saveText"
|
|
276
|
+
:create-text="createText"
|
|
277
|
+
:delete-text="deleteText"
|
|
242
278
|
@save="handleEditSave"
|
|
243
279
|
@cancel="handleEditCancel"
|
|
244
280
|
@delete="handleDelete"
|
|
@@ -266,6 +302,7 @@ import { api } from "../../utils/api";
|
|
|
266
302
|
import pluralize from "pluralize";
|
|
267
303
|
import { useResourceEditor } from "../../composables/useResourceEditor";
|
|
268
304
|
import { getByPath } from "../../utils/objectPath";
|
|
305
|
+
import type { FieldDefinition, LabelCols } from "../../types";
|
|
269
306
|
import DContainer from "../base/DContainer.vue";
|
|
270
307
|
import DRow from "../base/DRow.vue";
|
|
271
308
|
import DCol from "../base/DCol.vue";
|
|
@@ -351,6 +388,22 @@ export interface TableField {
|
|
|
351
388
|
filterOptions?: FilterOption[];
|
|
352
389
|
filterPlaceholder?: string;
|
|
353
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
|
+
|
|
354
407
|
/**
|
|
355
408
|
* Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
|
|
356
409
|
* is treated as pixels (`200` → `200px`); a string is used verbatim
|
|
@@ -415,6 +468,27 @@ export interface EditTab {
|
|
|
415
468
|
lazy?: boolean;
|
|
416
469
|
}
|
|
417
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Discriminated data-source descriptor (#130). The additive, type-safe
|
|
473
|
+
* replacement for the legacy `items` / `provider` / `apiUrl` / `inertiaUrl` /
|
|
474
|
+
* `clientSide` prop matrix, in which every prop was independently optional and
|
|
475
|
+
* the mode was inferred by precedence — so invalid combinations compiled and
|
|
476
|
+
* some were silently ignored.
|
|
477
|
+
*
|
|
478
|
+
* Pass ONE `source`: the mode is explicit and its required companion prop is
|
|
479
|
+
* enforced by the compiler (`{ mode: 'provider' }` without a `provider` won't
|
|
480
|
+
* type-check). The legacy props still work and take over when `source` is
|
|
481
|
+
* omitted; a future major removes them with a codemod (#130 Phase 3).
|
|
482
|
+
*
|
|
483
|
+
* `apiAdapter` (transport translation) and `pagination` (server-supplied page
|
|
484
|
+
* metadata) stay sibling props — they're orthogonal to which mode is active.
|
|
485
|
+
*/
|
|
486
|
+
export type DXTableSource<TItem = any> =
|
|
487
|
+
| { mode: 'client'; items: TItem[] }
|
|
488
|
+
| { mode: 'provider'; provider: BTableProvider<TItem> }
|
|
489
|
+
| { mode: 'api'; url: string }
|
|
490
|
+
| { mode: 'inertia'; items: TItem[]; url?: string };
|
|
491
|
+
|
|
418
492
|
export interface Props<TItem = any> {
|
|
419
493
|
/** Table title */
|
|
420
494
|
title?: string;
|
|
@@ -435,6 +509,14 @@ export interface Props<TItem = any> {
|
|
|
435
509
|
* backend convention that differs from dfl's (see DXTableApiAdapter). */
|
|
436
510
|
apiAdapter?: DXTableApiAdapter;
|
|
437
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Discriminated data-source (#130) — the type-safe alternative to the
|
|
514
|
+
* legacy `items` / `provider` / `apiUrl` / `inertiaUrl` / `clientSide`
|
|
515
|
+
* props. When set it WINS over those props; when omitted, the legacy props
|
|
516
|
+
* apply unchanged. See `DXTableSource`.
|
|
517
|
+
*/
|
|
518
|
+
source?: DXTableSource<TItem>;
|
|
519
|
+
|
|
438
520
|
/** Table field definitions */
|
|
439
521
|
fields: TableField[];
|
|
440
522
|
|
|
@@ -522,12 +604,29 @@ export interface Props<TItem = any> {
|
|
|
522
604
|
* borderless variant — the same header, filter row, table and pagination
|
|
523
605
|
* rendered directly on the page background (no card border/shadow), for
|
|
524
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, which requires the card
|
|
610
|
+
* to clip to its radius (`overflow: hidden`).
|
|
611
|
+
*
|
|
612
|
+
* **Consequence for slot content:** anything you render into a slot that
|
|
613
|
+
* positions itself OUTSIDE the card and does not teleport is clipped at the
|
|
614
|
+
* card edge. Library components are unaffected — `DDropdown` teleports to
|
|
615
|
+
* `<body>` by default, and the column-filter menus position themselves
|
|
616
|
+
* `fixed` — so this only bites a hand-rolled `position: absolute` overlay
|
|
617
|
+
* (a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or
|
|
618
|
+
* use `:card="false"`. Narrowing the clip to the table region is tracked
|
|
619
|
+
* as #166.
|
|
525
620
|
*/
|
|
526
621
|
card?: boolean;
|
|
527
622
|
|
|
528
623
|
// Edit Modal Props
|
|
529
|
-
/**
|
|
530
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Form field definitions for edit modal (if provided, enables edit on row
|
|
626
|
+
* click). `import type` is erased at compile time, so this creates no
|
|
627
|
+
* runtime import cycle (#131).
|
|
628
|
+
*/
|
|
629
|
+
editFields?: FieldDefinition[];
|
|
531
630
|
|
|
532
631
|
/** Tab definitions for organizing edit modal content */
|
|
533
632
|
editTabs?: EditTab[];
|
|
@@ -538,6 +637,43 @@ export interface Props<TItem = any> {
|
|
|
538
637
|
/** Edit modal size */
|
|
539
638
|
editModalSize?: 'sm' | 'md' | 'lg' | 'xl';
|
|
540
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Field layout for the edit/create modal's form, forwarded to its `DXForm`
|
|
642
|
+
* ("vertical" = label above input, "horizontal" = label left). Defaults to
|
|
643
|
+
* **"horizontal"** to match the Omni Tend form convention (a raw modal form
|
|
644
|
+
* previously fell back to DXForm's vertical default, which looked out of
|
|
645
|
+
* place next to horizontal page forms). Pass `"vertical"` to opt out.
|
|
646
|
+
*
|
|
647
|
+
* `"auto"` hands the decision to `DXForm`'s container-width observer, which
|
|
648
|
+
* measures the FORM rather than the window — so a modal narrowed by its own
|
|
649
|
+
* breakpoint stacks to vertical even on a wide viewport, where a media query
|
|
650
|
+
* would still report "plenty of room". See `useContainerWidth`.
|
|
651
|
+
*/
|
|
652
|
+
editLayout?: 'vertical' | 'horizontal' | 'auto';
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Label column width for the edit modal's horizontal layout, forwarded to
|
|
656
|
+
* `DXForm`'s `labelCols` (a single width or a per-breakpoint object). Only
|
|
657
|
+
* meaningful when `editLayout` is "horizontal".
|
|
658
|
+
*/
|
|
659
|
+
editLabelCols?: LabelCols;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Wrap the edit/create modal's form in a bordered card. Off by default (the
|
|
663
|
+
* modal body already provides a boundary); opt in for a more contained,
|
|
664
|
+
* panelled look around a tabbed edit form.
|
|
665
|
+
*/
|
|
666
|
+
editCard?: boolean;
|
|
667
|
+
|
|
668
|
+
/** Override the modal's Save button label (default: "Save {item}"). */
|
|
669
|
+
saveText?: string;
|
|
670
|
+
|
|
671
|
+
/** Override the modal's Create button label (default: "Create {item}"). */
|
|
672
|
+
createText?: string;
|
|
673
|
+
|
|
674
|
+
/** Override the modal's Delete button label (default: "Delete {item}"). */
|
|
675
|
+
deleteText?: string;
|
|
676
|
+
|
|
541
677
|
/** API endpoint pattern for updates (e.g., "/api/products/:id") */
|
|
542
678
|
editUrl?: string;
|
|
543
679
|
|
|
@@ -663,6 +799,8 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
663
799
|
columnSize: "12",
|
|
664
800
|
card: true,
|
|
665
801
|
editModalSize: "lg",
|
|
802
|
+
editLayout: "horizontal",
|
|
803
|
+
editCard: false,
|
|
666
804
|
showCreateButton: true,
|
|
667
805
|
footClone: false,
|
|
668
806
|
showEmpty: true,
|
|
@@ -704,15 +842,74 @@ const emit = defineEmits<{
|
|
|
704
842
|
'update:busy': [busy: boolean];
|
|
705
843
|
}>();
|
|
706
844
|
|
|
845
|
+
// Effective data-source values (#130). Prefer an explicit `source` prop, else
|
|
846
|
+
// fall back to the legacy `items`/`provider`/`apiUrl`/`inertiaUrl`/`clientSide`
|
|
847
|
+
// props. Every read below (mode detection, provider, client-side pipeline,
|
|
848
|
+
// inertia navigation) goes through these, so both APIs drive identical logic.
|
|
849
|
+
const resolvedClientSide = computed(() =>
|
|
850
|
+
props.source ? props.source.mode === 'client' : props.clientSide === true,
|
|
851
|
+
);
|
|
852
|
+
const resolvedItems = computed<T[] | undefined>(() => {
|
|
853
|
+
if (props.source) {
|
|
854
|
+
return props.source.mode === 'client' || props.source.mode === 'inertia'
|
|
855
|
+
? props.source.items
|
|
856
|
+
: undefined;
|
|
857
|
+
}
|
|
858
|
+
return props.items;
|
|
859
|
+
});
|
|
860
|
+
const resolvedProvider = computed<BTableProvider<T> | undefined>(() =>
|
|
861
|
+
props.source
|
|
862
|
+
? props.source.mode === 'provider'
|
|
863
|
+
? props.source.provider
|
|
864
|
+
: undefined
|
|
865
|
+
: props.provider,
|
|
866
|
+
);
|
|
867
|
+
const resolvedApiUrl = computed<string | undefined>(() =>
|
|
868
|
+
props.source
|
|
869
|
+
? props.source.mode === 'api'
|
|
870
|
+
? props.source.url
|
|
871
|
+
: undefined
|
|
872
|
+
: props.apiUrl,
|
|
873
|
+
);
|
|
874
|
+
const resolvedInertiaUrl = computed<string | undefined>(() =>
|
|
875
|
+
props.source
|
|
876
|
+
? props.source.mode === 'inertia'
|
|
877
|
+
? props.source.url
|
|
878
|
+
: undefined
|
|
879
|
+
: props.inertiaUrl,
|
|
880
|
+
);
|
|
881
|
+
|
|
707
882
|
// Mode detection
|
|
708
|
-
const isProviderMode = computed(() => !
|
|
709
|
-
const isInertiaMode = computed(() => !
|
|
710
|
-
const isClientSideMode = computed(() =>
|
|
711
|
-
const hasInertiaUrl = computed(() => !!
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
883
|
+
const isProviderMode = computed(() => !resolvedClientSide.value && (!!resolvedProvider.value || !!resolvedApiUrl.value));
|
|
884
|
+
const isInertiaMode = computed(() => !resolvedClientSide.value && !resolvedProvider.value && !resolvedApiUrl.value && !!resolvedItems.value);
|
|
885
|
+
const isClientSideMode = computed(() => resolvedClientSide.value && !!resolvedItems.value);
|
|
886
|
+
const hasInertiaUrl = computed(() => !!resolvedInertiaUrl.value);
|
|
887
|
+
|
|
888
|
+
// The active data-source mode, folded into the table's `:key` so a runtime
|
|
889
|
+
// mode switch (e.g. `source` provider → client) REMOUNTS the DTable. bvn's
|
|
890
|
+
// BTable caches its provider-fetched items internally and doesn't cleanly adopt
|
|
891
|
+
// a fresh `:items` binding when the provider is removed — without a remount the
|
|
892
|
+
// table shows "No items found" after switching to client/inertia mode. Stable
|
|
893
|
+
// for a table that never changes mode, so it adds no spurious remounts.
|
|
894
|
+
const activeMode = computed(() =>
|
|
895
|
+
isClientSideMode.value
|
|
896
|
+
? 'client'
|
|
897
|
+
: isInertiaMode.value
|
|
898
|
+
? 'inertia'
|
|
899
|
+
: isProviderMode.value
|
|
900
|
+
? 'provider'
|
|
901
|
+
: 'empty',
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
// Warn about invalid LEGACY prop combinations (a `source` prop makes these
|
|
905
|
+
// impossible at the type level, so it's exempt). #130: `clientSide + provider`
|
|
906
|
+
// was previously uncovered by this guard.
|
|
907
|
+
if (
|
|
908
|
+
!props.source &&
|
|
909
|
+
props.clientSide &&
|
|
910
|
+
(props.provider || props.apiUrl || props.inertiaUrl)
|
|
911
|
+
) {
|
|
912
|
+
console.warn('[DXTable] clientSide mode ignores provider, apiUrl and inertiaUrl props. Data is processed locally from items.');
|
|
716
913
|
}
|
|
717
914
|
|
|
718
915
|
// Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
|
|
@@ -835,7 +1032,7 @@ const paginationWasProvided = 'pagination' in (instance?.vnode.props ?? {});
|
|
|
835
1032
|
|
|
836
1033
|
const providerPagination = computed<PaginationData | null>(() => {
|
|
837
1034
|
if (apiPaginationMeta.value) return apiPaginationMeta.value;
|
|
838
|
-
if (
|
|
1035
|
+
if (resolvedProvider.value && paginationWasProvided && props.pagination) {
|
|
839
1036
|
return props.pagination;
|
|
840
1037
|
}
|
|
841
1038
|
return null;
|
|
@@ -850,7 +1047,7 @@ const providerPagination = computed<PaginationData | null>(() => {
|
|
|
850
1047
|
const providerHasRun = ref(false);
|
|
851
1048
|
let warnedAboutProviderPagination = false;
|
|
852
1049
|
watch(
|
|
853
|
-
() => [
|
|
1050
|
+
() => [resolvedProvider.value, props.showPagination, providerPagination.value, providerHasRun.value] as const,
|
|
854
1051
|
([provider, showPagination, pagination, hasRun]) => {
|
|
855
1052
|
if (warnedAboutProviderPagination || !hasRun) return;
|
|
856
1053
|
if (provider && showPagination && !pagination) {
|
|
@@ -873,29 +1070,20 @@ const clientSideCurrentPage = ref(1);
|
|
|
873
1070
|
|
|
874
1071
|
// Client-side filtered items
|
|
875
1072
|
const clientSideFilteredItems = computed<T[]>(() => {
|
|
876
|
-
if (!isClientSideMode.value || !
|
|
1073
|
+
if (!isClientSideMode.value || !resolvedItems.value) return [];
|
|
877
1074
|
|
|
878
1075
|
const filters = effectiveFilters.value;
|
|
879
1076
|
const filterKeys = Object.keys(filters).filter(key => isActiveFilterValue(filters[key]));
|
|
880
1077
|
|
|
881
1078
|
if (filterKeys.length === 0) {
|
|
882
|
-
return
|
|
1079
|
+
return resolvedItems.value;
|
|
883
1080
|
}
|
|
884
1081
|
|
|
885
|
-
return
|
|
1082
|
+
return resolvedItems.value.filter(item => {
|
|
886
1083
|
return filterKeys.every(key => {
|
|
887
1084
|
// Resolve by the FILTER key, which may differ from the column's key.
|
|
888
1085
|
const field = props.fields.find(f => filterKeyFor(f) === key);
|
|
889
|
-
|
|
890
|
-
// not carry it at all (a `customer_name` row filtered on
|
|
891
|
-
// `customer_id`). Match on the filter key when the row has it,
|
|
892
|
-
// otherwise fall back to the column's own value — which is also the
|
|
893
|
-
// one the user can actually see and type against.
|
|
894
|
-
// Both lookups are dot-path aware, so a nested payload filters (#121).
|
|
895
|
-
const itemValue =
|
|
896
|
-
field && hasFieldValue(item, key)
|
|
897
|
-
? fieldValueOf(item, key)
|
|
898
|
-
: fieldValueOf(item, field?.key ?? key);
|
|
1086
|
+
const itemValue = clientSideFilterValueOf(item, field, key);
|
|
899
1087
|
|
|
900
1088
|
const matchesCandidate = (candidate: string): boolean => {
|
|
901
1089
|
// The "no value" option (e.g. Unassigned) is the one filter that
|
|
@@ -1017,6 +1205,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
|
|
|
1017
1205
|
return getByPath(item as Record<string, any>, key) !== undefined;
|
|
1018
1206
|
};
|
|
1019
1207
|
|
|
1208
|
+
/*
|
|
1209
|
+
* The value a client-side filter compares against, for one row and one column.
|
|
1210
|
+
*
|
|
1211
|
+
* `filterKey` names the param the SERVER filters on; a local row may not carry
|
|
1212
|
+
* it at all (a `customer_name` row filtered on `customer_id`). Read the filter
|
|
1213
|
+
* key when the row has it, otherwise fall back to the column's own value —
|
|
1214
|
+
* which is also the one the user can actually see and type against. Both
|
|
1215
|
+
* lookups are dot-path aware, so a nested payload filters (#121).
|
|
1216
|
+
*
|
|
1217
|
+
* Extracted so the client-side row filter and the DERIVED select options (see
|
|
1218
|
+
* `derivedFilterOptions`) resolve values through exactly the same rule — a
|
|
1219
|
+
* derived option must never be a value the filter then fails to match.
|
|
1220
|
+
*/
|
|
1221
|
+
const clientSideFilterValueOf = (
|
|
1222
|
+
item: unknown,
|
|
1223
|
+
field: TableField | undefined,
|
|
1224
|
+
filterKey: string,
|
|
1225
|
+
): any =>
|
|
1226
|
+
field && hasFieldValue(item, filterKey)
|
|
1227
|
+
? fieldValueOf(item, filterKey)
|
|
1228
|
+
: fieldValueOf(item, field?.key ?? filterKey);
|
|
1229
|
+
|
|
1020
1230
|
/*
|
|
1021
1231
|
* Dotted-key columns DXTable must render itself, because bvn renders them empty
|
|
1022
1232
|
* (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
|
|
@@ -1101,6 +1311,115 @@ const filterAllLabelFor = (field: TableField): string =>
|
|
|
1101
1311
|
const headerLabel = (field: TableField, slotLabel?: string): string =>
|
|
1102
1312
|
field.label !== undefined ? field.label : (slotLabel || field.key);
|
|
1103
1313
|
|
|
1314
|
+
/**
|
|
1315
|
+
* A select filter option's LABEL, honouring the column's own value→label
|
|
1316
|
+
* formatting so the dropdown reads the way the column does (a `formatter`
|
|
1317
|
+
* turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
|
|
1318
|
+
* the client-side filter compares against (`String(itemValue) === candidate`),
|
|
1319
|
+
* so formatting must never leak into it.
|
|
1320
|
+
*
|
|
1321
|
+
* `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
|
|
1322
|
+
* its first argument is by contract the value of `field.key` — the DISPLAYED
|
|
1323
|
+
* column value. That matters when `filterKey` names a different property: the
|
|
1324
|
+
* option's value must stay the FILTER value (that is what the row filter
|
|
1325
|
+
* compares), but the formatter must still be handed the display value it was
|
|
1326
|
+
* written against. Passing the filter value here fed a `customer` column's
|
|
1327
|
+
* `(customer) => customer.name` formatter a numeric `customer_id`, which
|
|
1328
|
+
* mislabels at best and throws at worst.
|
|
1329
|
+
*/
|
|
1330
|
+
const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
|
|
1331
|
+
if (typeof field.formatter !== 'function') return null;
|
|
1332
|
+
const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
|
|
1333
|
+
if (formatted === null || formatted === undefined) return null;
|
|
1334
|
+
return String(formatted);
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Client-side auto-derived `filter: "select"` options, keyed by field key.
|
|
1339
|
+
*
|
|
1340
|
+
* Without this a client-side select column with no `filterOptions` and no
|
|
1341
|
+
* server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
|
|
1342
|
+
* bug — so this is on by default (opt out per column with
|
|
1343
|
+
* `deriveFilterOptions: false`).
|
|
1344
|
+
*
|
|
1345
|
+
* It derives from `resolvedItems` (the FULL loaded row set) and deliberately
|
|
1346
|
+
* NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
|
|
1347
|
+
* - filtered set → picking a value would collapse the dropdown to that one
|
|
1348
|
+
* value, and the user could never get back to any other one;
|
|
1349
|
+
* - current page → the offered values would silently change as you page.
|
|
1350
|
+
* Being a computed over `props.fields` + `resolvedItems` only, it is also
|
|
1351
|
+
* structurally incapable of depending on the active filters — the cache is the
|
|
1352
|
+
* guard, not just the comment.
|
|
1353
|
+
*/
|
|
1354
|
+
const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
|
|
1355
|
+
// Only client-side mode has the complete row set. Provider/API and Inertia
|
|
1356
|
+
// hold one page, so they take their options from the server instead.
|
|
1357
|
+
if (!isClientSideMode.value) return {};
|
|
1358
|
+
|
|
1359
|
+
const rows = resolvedItems.value;
|
|
1360
|
+
if (!rows || rows.length === 0) return {};
|
|
1361
|
+
|
|
1362
|
+
const optionsByFieldKey: Record<string, FilterOption[]> = {};
|
|
1363
|
+
|
|
1364
|
+
for (const field of props.fields) {
|
|
1365
|
+
if (field.filter !== 'select') continue;
|
|
1366
|
+
if (field.deriveFilterOptions === false) continue;
|
|
1367
|
+
// Explicit options win outright (see `getFieldFilterOptions`), so don't
|
|
1368
|
+
// spend a pass over every row producing a list nothing will read.
|
|
1369
|
+
if (field.filterOptions && field.filterOptions.length > 0) continue;
|
|
1370
|
+
|
|
1371
|
+
const filterKey = filterKeyFor(field);
|
|
1372
|
+
|
|
1373
|
+
// Distinct values keyed by their STRING form, because that is exactly
|
|
1374
|
+
// what the client-side select filter compares — so `1` and `"1"` are one
|
|
1375
|
+
// option, and every option offered is one the filter can match. The
|
|
1376
|
+
// first row carrying a value is kept alongside it, as the `item`
|
|
1377
|
+
// argument the column's formatter expects.
|
|
1378
|
+
const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
|
|
1379
|
+
|
|
1380
|
+
// Candidates the filter reads as CONTROL values rather than data, so a
|
|
1381
|
+
// row whose value collides with one can never be matched by selecting it
|
|
1382
|
+
// (`handleSelectFilterChange` turns the "all" sentinel back into "no
|
|
1383
|
+
// filter", and the null sentinel is intercepted upstream of the select's
|
|
1384
|
+
// exact-match arm, where it means "has no value"). Offering such an
|
|
1385
|
+
// option would be a dropdown entry that returns zero rows, and the null
|
|
1386
|
+
// one would also duplicate the `filterNullText` entry's value.
|
|
1387
|
+
const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
|
|
1388
|
+
if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
|
|
1389
|
+
|
|
1390
|
+
for (const item of rows) {
|
|
1391
|
+
const value = clientSideFilterValueOf(item, field, filterKey);
|
|
1392
|
+
// null / undefined / '' are skipped deliberately: "has no value" is
|
|
1393
|
+
// already a first-class, LABELLED option via `filterNullText`, and
|
|
1394
|
+
// an unlabelled blank row in the list would be unusable.
|
|
1395
|
+
if (value === null || value === undefined || value === '') continue;
|
|
1396
|
+
const stringValue = String(value);
|
|
1397
|
+
if (reservedCandidates.has(stringValue)) continue;
|
|
1398
|
+
if (!firstRowByValue.has(stringValue)) {
|
|
1399
|
+
firstRowByValue.set(stringValue, { rawValue: value, item });
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const entries = [...firstRowByValue.entries()];
|
|
1404
|
+
|
|
1405
|
+
// Stable, human ordering: numeric when both values are numbers,
|
|
1406
|
+
// otherwise a locale compare — the same rule the client-side sort uses,
|
|
1407
|
+
// with `numeric: true` so "Item 2" precedes "Item 10".
|
|
1408
|
+
entries.sort(([leftText, left], [rightText, right]) =>
|
|
1409
|
+
typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
|
|
1410
|
+
? left.rawValue - right.rawValue
|
|
1411
|
+
: leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
|
|
1415
|
+
value: stringValue,
|
|
1416
|
+
text: formattedFilterLabel(field, item) ?? stringValue,
|
|
1417
|
+
}));
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
return optionsByFieldKey;
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1104
1423
|
const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
1105
1424
|
const options: FilterOption[] = [];
|
|
1106
1425
|
|
|
@@ -1140,6 +1459,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1140
1459
|
return [...options, ...serverValues.map(value => ({ value, text: value }))];
|
|
1141
1460
|
}
|
|
1142
1461
|
|
|
1462
|
+
// Last resort, CLIENT-SIDE only: the distinct values present in the loaded
|
|
1463
|
+
// rows. Precedence is deliberate — explicit `filterOptions`, then the
|
|
1464
|
+
// server's `filterValues`, then what the data itself says. See
|
|
1465
|
+
// `derivedFilterOptions` for why it derives from the unfiltered row set.
|
|
1466
|
+
const derived = derivedFilterOptions.value[field.key];
|
|
1467
|
+
if (derived && derived.length > 0) {
|
|
1468
|
+
return [...options, ...derived];
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1143
1471
|
// No values to choose from — don't offer a lone "All x" that filters nothing.
|
|
1144
1472
|
return [];
|
|
1145
1473
|
};
|
|
@@ -1151,7 +1479,7 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
|
|
|
1151
1479
|
// Returns null when there is nothing stable to key on — every read and write
|
|
1152
1480
|
// guards on that, so a keyless table simply doesn't persist.
|
|
1153
1481
|
const perPageStorageKey = computed<string | null>(() => {
|
|
1154
|
-
const url =
|
|
1482
|
+
const url = resolvedInertiaUrl.value || resolvedApiUrl.value;
|
|
1155
1483
|
if (!url) return null;
|
|
1156
1484
|
return `dxtable-perpage-${url.replace(/\//g, '-')}`;
|
|
1157
1485
|
});
|
|
@@ -1232,7 +1560,7 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
|
|
|
1232
1560
|
} else if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1233
1561
|
// Inertia mode - trigger navigation with new filters
|
|
1234
1562
|
router.get(
|
|
1235
|
-
|
|
1563
|
+
resolvedInertiaUrl.value!,
|
|
1236
1564
|
{
|
|
1237
1565
|
page: 1, // Reset to first page when filters change
|
|
1238
1566
|
filters: props.filters,
|
|
@@ -1248,8 +1576,15 @@ watch(() => JSON.stringify(props.filters ?? null), () => {
|
|
|
1248
1576
|
// of BTable's provider context, so an api-url-only change doesn't re-invoke the
|
|
1249
1577
|
// provider on its own — without this the table keeps showing the previous url's
|
|
1250
1578
|
// rows until some other trigger (sort, per-page, create/edit/delete) fires.
|
|
1251
|
-
watch(() =>
|
|
1252
|
-
|
|
1579
|
+
watch(() => resolvedApiUrl.value, (newUrl, oldUrl) => {
|
|
1580
|
+
// `newUrl` must be truthy: an api-url → provider transition drops the url to
|
|
1581
|
+
// `undefined` while still satisfying `isProviderMode` via the new provider.
|
|
1582
|
+
// Without this guard both this watcher and the provider watcher below run
|
|
1583
|
+
// their clear+refresh on that transition (bvn coalesces the two refreshes
|
|
1584
|
+
// into one fetch, so it isn't an observable double-fetch — but the redundant
|
|
1585
|
+
// work is avoidable). The provider watcher owns the url-removal transition;
|
|
1586
|
+
// this one only fires for an actual new url.
|
|
1587
|
+
if (newUrl && newUrl !== oldUrl && isProviderMode.value) {
|
|
1253
1588
|
// Clear cached filter values and pagination when API endpoint changes.
|
|
1254
1589
|
// The next provider call will request fresh filter values.
|
|
1255
1590
|
apiFilterValues.value = {};
|
|
@@ -1268,6 +1603,25 @@ watch(() => props.apiUrl, (newUrl, oldUrl) => {
|
|
|
1268
1603
|
}
|
|
1269
1604
|
});
|
|
1270
1605
|
|
|
1606
|
+
// A `provider` swap (`:provider` or `source.provider` reassigned to a new
|
|
1607
|
+
// function) must refetch, exactly as an `apiUrl` swap does above. `effectiveProvider`'s
|
|
1608
|
+
// identity is deliberately STABLE (#82) so BTable doesn't refetch on its own
|
|
1609
|
+
// when the underlying provider changes — so, like the apiUrl watcher, force it.
|
|
1610
|
+
// Only fires for an actual provider function (apiUrl mode is covered above), and
|
|
1611
|
+
// `watch` skips the initial value, so no double-fetch on mount.
|
|
1612
|
+
watch(() => resolvedProvider.value, (newProvider, oldProvider) => {
|
|
1613
|
+
if (newProvider !== oldProvider && !!newProvider) {
|
|
1614
|
+
apiFilterValues.value = {};
|
|
1615
|
+
apiPaginationMeta.value = null;
|
|
1616
|
+
apiError.value = null;
|
|
1617
|
+
const wasOnFirstPage = apiCurrentPage.value === 1;
|
|
1618
|
+
apiCurrentPage.value = 1;
|
|
1619
|
+
if (wasOnFirstPage) {
|
|
1620
|
+
refresh();
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1271
1625
|
// Computed effective perPage (use external if provided, otherwise internal)
|
|
1272
1626
|
const effectivePerPage = computed(() => {
|
|
1273
1627
|
// Only when the consumer is actually driving it (v-model). A bare
|
|
@@ -1330,7 +1684,7 @@ const clientSidePaginatedItems = computed<T[]>(() => {
|
|
|
1330
1684
|
// Client-side pagination metadata
|
|
1331
1685
|
const clientSidePagination = computed<PaginationData>(() => {
|
|
1332
1686
|
const total = clientSideFilteredItems.value.length;
|
|
1333
|
-
const totalUnfiltered =
|
|
1687
|
+
const totalUnfiltered = resolvedItems.value?.length || 0;
|
|
1334
1688
|
const perPage = effectivePerPage.value;
|
|
1335
1689
|
const lastPage = clientSideLastPage.value;
|
|
1336
1690
|
|
|
@@ -1389,7 +1743,12 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1389
1743
|
return total >= smallestOption;
|
|
1390
1744
|
});
|
|
1391
1745
|
|
|
1392
|
-
// Detect which fields need server filter values
|
|
1746
|
+
// Detect which fields need server filter values.
|
|
1747
|
+
//
|
|
1748
|
+
// Client-side derived options (`derivedFilterOptions`) deliberately do NOT
|
|
1749
|
+
// shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
|
|
1750
|
+
// and `isProviderMode` requires `!resolvedClientSide` — the two modes are
|
|
1751
|
+
// mutually exclusive, so a client-side derived column can never appear here.
|
|
1393
1752
|
const fieldsNeedingFilterValues = computed(() => {
|
|
1394
1753
|
return props.fields
|
|
1395
1754
|
.filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
|
|
@@ -1426,7 +1785,7 @@ const sortParams = (sortBy: readonly BTableSortBy[] | undefined) => {
|
|
|
1426
1785
|
// catch: `effectiveProvider` wraps every provider (including a consumer's own)
|
|
1427
1786
|
// in one error handler, so no provider can fail silently.
|
|
1428
1787
|
const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1429
|
-
if (!
|
|
1788
|
+
if (!resolvedApiUrl.value) return [];
|
|
1430
1789
|
|
|
1431
1790
|
const params: any = {
|
|
1432
1791
|
page: context.currentPage,
|
|
@@ -1448,7 +1807,7 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
|
|
|
1448
1807
|
? props.apiAdapter.request(params)
|
|
1449
1808
|
: params;
|
|
1450
1809
|
|
|
1451
|
-
const response = await api.get<any>(
|
|
1810
|
+
const response = await api.get<any>(resolvedApiUrl.value, wireParams);
|
|
1452
1811
|
|
|
1453
1812
|
// Adapt a foreign response envelope back into the dfl shape. Gets the
|
|
1454
1813
|
// ORIGINAL dfl-shape params (page/perPage/…) for context, since a foreign
|
|
@@ -1485,14 +1844,24 @@ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProvi
|
|
|
1485
1844
|
* a blank page to a user. A consumer's provider used to be the silent case: the
|
|
1486
1845
|
* error handling lived inside the internal one, so it never covered them.
|
|
1487
1846
|
*/
|
|
1847
|
+
// Monotonic generation for provider calls. When a provider is swapped (or a
|
|
1848
|
+
// refetch supersedes an in-flight call), a still-pending older call that later
|
|
1849
|
+
// REJECTS must not publish its error over the newer call's rows — bvn already
|
|
1850
|
+
// discards a superseded call's rows, but `apiError` isn't otherwise guarded.
|
|
1851
|
+
let providerGeneration = 0;
|
|
1852
|
+
|
|
1488
1853
|
const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
|
|
1489
|
-
const provider =
|
|
1854
|
+
const provider = resolvedProvider.value || (resolvedApiUrl.value ? internalProvider : undefined);
|
|
1490
1855
|
if (!provider) return [];
|
|
1491
1856
|
|
|
1857
|
+
const generation = ++providerGeneration;
|
|
1492
1858
|
apiError.value = null;
|
|
1493
1859
|
try {
|
|
1494
1860
|
return await provider(context);
|
|
1495
1861
|
} catch (error: any) {
|
|
1862
|
+
// Superseded by a newer provider call — its result/error owns the table
|
|
1863
|
+
// now, so drop this stale rejection silently.
|
|
1864
|
+
if (generation !== providerGeneration) return [];
|
|
1496
1865
|
// eslint-disable-next-line no-console
|
|
1497
1866
|
console.error('DXTable: the data provider failed', error);
|
|
1498
1867
|
// `error.message` covers the internal provider's ApiError; the
|
|
@@ -1512,14 +1881,14 @@ const wrappedProvider: BTableProvider<T> = async (context: Readonly<BTableProvid
|
|
|
1512
1881
|
// function changes, which would double every request that the explicit apiUrl
|
|
1513
1882
|
// watcher already refetches (#82).
|
|
1514
1883
|
const effectiveProvider = computed<BTableProvider<T> | undefined>(() =>
|
|
1515
|
-
|
|
1884
|
+
resolvedProvider.value || resolvedApiUrl.value ? wrappedProvider : undefined,
|
|
1516
1885
|
);
|
|
1517
1886
|
|
|
1518
1887
|
const handlePageChange = (page: number) => {
|
|
1519
1888
|
// If inertiaUrl provided, handle navigation automatically
|
|
1520
1889
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1521
1890
|
router.get(
|
|
1522
|
-
|
|
1891
|
+
resolvedInertiaUrl.value!,
|
|
1523
1892
|
{
|
|
1524
1893
|
page,
|
|
1525
1894
|
filters: effectiveFilters.value,
|
|
@@ -1574,7 +1943,7 @@ const tableModeBindings = computed<Record<string, unknown>>(() => {
|
|
|
1574
1943
|
return { items: clientSidePaginatedItems.value, noLocalSorting: true };
|
|
1575
1944
|
}
|
|
1576
1945
|
// inertia
|
|
1577
|
-
return { items:
|
|
1946
|
+
return { items: resolvedItems.value, noLocalSorting: true, busy: effectiveBusy.value };
|
|
1578
1947
|
});
|
|
1579
1948
|
|
|
1580
1949
|
/*
|
|
@@ -1627,7 +1996,7 @@ const handleSortChange = (sortBy: BTableSortBy[]) => {
|
|
|
1627
1996
|
// The cleared state has a key but no order — `sortParams` sends nothing
|
|
1628
1997
|
// for it, rather than inventing an ascending sort the user didn't ask for.
|
|
1629
1998
|
router.get(
|
|
1630
|
-
|
|
1999
|
+
resolvedInertiaUrl.value!,
|
|
1631
2000
|
{
|
|
1632
2001
|
page: props.pagination?.current_page || 1,
|
|
1633
2002
|
filters: effectiveFilters.value,
|
|
@@ -1684,7 +2053,7 @@ const handlePerPageChange = (newPerPage: number | string | null | (number | stri
|
|
|
1684
2053
|
// Handle navigation automatically
|
|
1685
2054
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1686
2055
|
router.get(
|
|
1687
|
-
|
|
2056
|
+
resolvedInertiaUrl.value!,
|
|
1688
2057
|
{
|
|
1689
2058
|
page: 1, // Reset to first page when changing perPage
|
|
1690
2059
|
filters: effectiveFilters.value,
|
|
@@ -1737,7 +2106,7 @@ const handleFilterChange = (fieldKey: string, value: string | string[]) => {
|
|
|
1737
2106
|
// Handle Inertia navigation automatically if URL provided
|
|
1738
2107
|
if (hasInertiaUrl.value && isInertiaMode.value && router) {
|
|
1739
2108
|
router.get(
|
|
1740
|
-
|
|
2109
|
+
resolvedInertiaUrl.value!,
|
|
1741
2110
|
{
|
|
1742
2111
|
page: 1, // Reset to first page when filtering
|
|
1743
2112
|
filters: newFilters,
|
|
@@ -1769,7 +2138,7 @@ const refresh = () => {
|
|
|
1769
2138
|
(tableRef.value as any).refresh();
|
|
1770
2139
|
}
|
|
1771
2140
|
// Inertia mode: reload current page to refresh data
|
|
1772
|
-
else if (isInertiaMode.value &&
|
|
2141
|
+
else if (isInertiaMode.value && resolvedInertiaUrl.value && router) {
|
|
1773
2142
|
router.reload();
|
|
1774
2143
|
}
|
|
1775
2144
|
};
|
|
@@ -1786,6 +2155,12 @@ const refresh = () => {
|
|
|
1786
2155
|
* - `head(<key>)` — DXTable draws its own column headers (sort indicators,
|
|
1787
2156
|
* field hints). Forwarding a consumer's would silently drop those.
|
|
1788
2157
|
* - `thead-top` — DXTable renders the inline filter row there.
|
|
2158
|
+
*
|
|
2159
|
+
* `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
|
|
2160
|
+
* not forwarded: the inner table has no such slot, and the prefixes below are
|
|
2161
|
+
* matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
|
|
2162
|
+
* `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
|
|
2163
|
+
* (which filters on `isTableSlot`), so adding one never remounts the table.
|
|
1789
2164
|
*/
|
|
1790
2165
|
const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
|
|
1791
2166
|
const TABLE_SLOT_NAMES = new Set([
|