@jielga/tmdatagrid 2.0.0-beta.11 → 2.0.0-beta.13
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/index.d.ts +285 -22
- package/dist/index.js +2857 -2376
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -2
- package/skills/appearance/SKILL.md +1 -1
- package/skills/cell-selection/SKILL.md +1 -1
- package/skills/columns/SKILL.md +2 -2
- package/skills/data/SKILL.md +5 -2
- package/skills/editing/SKILL.md +1 -1
- package/skills/filtering/SKILL.md +112 -26
- package/skills/getting-started/SKILL.md +4 -4
- package/skills/grouping/SKILL.md +1 -1
- package/skills/options/SKILL.md +3 -1
- package/skills/rows/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +44 -15
- package/skills/testing/SKILL.md +6 -3
- package/src/tmdatagrid/components/TMDataGrid.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +37 -18
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +138 -160
- package/src/tmdatagrid/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/tmdatagrid/components/TMDataGridFilterSurface.tsx +162 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +28 -1
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +10 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +12 -3
- package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/tmdatagrid/components/TMDataGridHeaderFilterRow.tsx +303 -0
- package/src/tmdatagrid/components/TMDataGridTable.module.css +9 -41
- package/src/tmdatagrid/components/TMDataGridTable.tsx +84 -15
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +37 -11
- package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +5 -4
- package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +22 -5
- package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +5 -1
- package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +5 -1
- package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +44 -28
- package/src/tmdatagrid/components/filters/controlLayout.ts +32 -0
- package/src/tmdatagrid/components/filters/filterControlFor.ts +65 -0
- package/src/tmdatagrid/components/sticky.module.css +44 -0
- package/src/tmdatagrid/core/columnOptions.ts +46 -0
- package/src/tmdatagrid/core/controlledStateSync.ts +108 -0
- package/src/tmdatagrid/core/editEngine.ts +68 -12
- package/src/tmdatagrid/core/filterControls.ts +28 -0
- package/src/tmdatagrid/core/filterOperators.ts +64 -1
- package/src/tmdatagrid/core/filterSurface.ts +99 -0
- package/src/tmdatagrid/core/labels.ts +7 -0
- package/src/tmdatagrid/core/labelsSv.ts +3 -0
- package/src/tmdatagrid/core/pageReset.ts +115 -0
- package/src/tmdatagrid/index.ts +13 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +191 -11
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Column } from "@tanstack/react-table";
|
|
2
|
+
import { resolveColumnOptions, type TMDataGridOption } from "../../core/columnOptions";
|
|
3
|
+
import { getColumnFilterControl, getColumnType } from "../../core/columnUtils";
|
|
4
|
+
import type { TMDataGridFilterControlComponent } from "../../core/filterControls";
|
|
5
|
+
import type { TMDataGridRowData } from "../../TMDataGridContext";
|
|
6
|
+
import type { TMDataGridFeatures, TMDataGridTable } from "../../useTMDataGrid";
|
|
7
|
+
import { TMDataGridFilterValueInput } from "./TMDataGridFilterValueInput";
|
|
8
|
+
|
|
9
|
+
type FilterColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
10
|
+
|
|
11
|
+
/** One array, so "this column has no options" never changes identity. */
|
|
12
|
+
const NO_OPTIONS: ReadonlyArray<TMDataGridOption> = [];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether resolving this column's options would read the faceted index -
|
|
16
|
+
* which is what makes the resolution worth memoizing, and what it goes stale
|
|
17
|
+
* against.
|
|
18
|
+
*/
|
|
19
|
+
export function filterOptionsUseFacets(column: FilterColumn): boolean {
|
|
20
|
+
const declared = column.columnDef.meta?.options;
|
|
21
|
+
return (
|
|
22
|
+
columnNeedsFilterOptions(column) &&
|
|
23
|
+
(declared === undefined || declared === "faceted")
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a column's filter control is offered a list of options at all.
|
|
29
|
+
*
|
|
30
|
+
* Only where options mean something out of the box - a declared set, or a
|
|
31
|
+
* select-shaped column's faceted values. A custom control wanting faceted
|
|
32
|
+
* values on some other column resolves them itself; resolving here would build
|
|
33
|
+
* the faceted index for every filtered column.
|
|
34
|
+
*/
|
|
35
|
+
function columnNeedsFilterOptions(column: FilterColumn): boolean {
|
|
36
|
+
const type = getColumnType(column);
|
|
37
|
+
return (
|
|
38
|
+
column.columnDef.meta?.options !== undefined ||
|
|
39
|
+
type === "select" ||
|
|
40
|
+
type === "multiSelect"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a column's filter control is, and what options it is handed - the one
|
|
46
|
+
* decision the panel row and the header cell make identically.
|
|
47
|
+
*
|
|
48
|
+
* Not a hook: the panel resolves this inside a `map` over its rows, where a
|
|
49
|
+
* hook cannot go. The header row memoizes the call itself, because it
|
|
50
|
+
* re-renders with the table on every scroll frame.
|
|
51
|
+
*/
|
|
52
|
+
export function filterControlFor(
|
|
53
|
+
table: TMDataGridTable<TMDataGridRowData>,
|
|
54
|
+
column: FilterColumn,
|
|
55
|
+
): {
|
|
56
|
+
options: ReadonlyArray<TMDataGridOption>;
|
|
57
|
+
ValueControl: TMDataGridFilterControlComponent;
|
|
58
|
+
} {
|
|
59
|
+
return {
|
|
60
|
+
options: columnNeedsFilterOptions(column)
|
|
61
|
+
? resolveColumnOptions({ table, column, fallback: "faceted" })
|
|
62
|
+
: NO_OPTIONS,
|
|
63
|
+
ValueControl: getColumnFilterControl(column) ?? TMDataGridFilterValueInput,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -104,3 +104,47 @@
|
|
|
104
104
|
opacity: 0;
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
/* The scrolled-under shadow: a soft band under the header, only while body
|
|
109
|
+
rows are actually beneath it. Worn by the last header row - the header/body
|
|
110
|
+
boundary - which is the filter row under `filters.inHeader` and the last
|
|
111
|
+
group row otherwise. Lives here rather than in either row's own module
|
|
112
|
+
because the two are in different modules and the boundary moves between
|
|
113
|
+
them.
|
|
114
|
+
|
|
115
|
+
A scroll-driven animation, like the pinned-lane gradients: the shadow tracks
|
|
116
|
+
the scroll on the compositor with no listener and no render, and an inactive
|
|
117
|
+
timeline (nothing to scroll) leaves `opacity: 0` standing, so a grid that
|
|
118
|
+
fits shows nothing. Where unsupported there is simply no shadow - the
|
|
119
|
+
header's border already draws the boundary. */
|
|
120
|
+
.headerBoundary::after {
|
|
121
|
+
content: "";
|
|
122
|
+
position: absolute;
|
|
123
|
+
inset: 100% 0 auto 0;
|
|
124
|
+
height: 6px;
|
|
125
|
+
pointer-events: none;
|
|
126
|
+
background: linear-gradient(
|
|
127
|
+
to bottom,
|
|
128
|
+
var(--dg-header-shadow-color, rgba(0, 0, 0, 0.14)),
|
|
129
|
+
transparent
|
|
130
|
+
);
|
|
131
|
+
opacity: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@supports (animation-timeline: scroll()) {
|
|
135
|
+
.headerBoundary::after {
|
|
136
|
+
animation: dgHeaderShadow linear both;
|
|
137
|
+
animation-timeline: scroll(nearest block);
|
|
138
|
+
/* Arrives over the first rows leaving, reads as depth not as a fade. */
|
|
139
|
+
animation-range: 0px 24px;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes dgHeaderShadow {
|
|
144
|
+
from {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
}
|
|
147
|
+
to {
|
|
148
|
+
opacity: 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -45,6 +45,51 @@ export type TMDataGridOptionsSource =
|
|
|
45
45
|
| "faceted"
|
|
46
46
|
| ((args: TMDataGridOptionsArgs) => ReadonlyArray<TMDataGridOption | string>);
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Columns already warned about, per grid - so a warning fires once and a
|
|
50
|
+
* test's grid is not silenced by another test's. Keyed on `table.store`, not
|
|
51
|
+
* on the table: `useTable` returns a fresh table object every render, while
|
|
52
|
+
* the store is created once and shared by every render's copy.
|
|
53
|
+
*/
|
|
54
|
+
const warnedFaceted = new WeakMap<object, Set<string>>();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Faceted options read the distinct values in `data`, which under
|
|
58
|
+
* `manualFiltering` or `manualPagination` is whatever the server sent for the
|
|
59
|
+
* current page. The dropdown then offers the values that happen to be on the
|
|
60
|
+
* page the user is looking at, and looks correct while being wrong - so it is
|
|
61
|
+
* said out loud, once per column.
|
|
62
|
+
*
|
|
63
|
+
* Fires from render, unlike the library's other warnings: the fallback form
|
|
64
|
+
* of `"faceted"` only exists at resolve time, which is render. The guard
|
|
65
|
+
* makes it once per grid regardless - a StrictMode double render or a
|
|
66
|
+
* discarded concurrent render marks the set the same way a committed one
|
|
67
|
+
* does. H3 in the backlog folds it into the diagnostics mechanism with the
|
|
68
|
+
* rest.
|
|
69
|
+
*/
|
|
70
|
+
function warnFacetedUnderManualMode(
|
|
71
|
+
table: TMDataGridTable<TMDataGridRowData>,
|
|
72
|
+
columnId: string,
|
|
73
|
+
): void {
|
|
74
|
+
if (
|
|
75
|
+
table.options.manualFiltering !== true &&
|
|
76
|
+
table.options.manualPagination !== true
|
|
77
|
+
) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const store = table.store as object;
|
|
81
|
+
let warned = warnedFaceted.get(store);
|
|
82
|
+
if (warned === undefined) {
|
|
83
|
+
warned = new Set();
|
|
84
|
+
warnedFaceted.set(store, warned);
|
|
85
|
+
}
|
|
86
|
+
if (warned.has(columnId)) return;
|
|
87
|
+
warned.add(columnId);
|
|
88
|
+
console.warn(
|
|
89
|
+
`TMDataGrid: column "${columnId}" resolves faceted options while the server owns the rows - the distinct values of one page are not the distinct values of the result set. Pass meta.options as a list or a function instead.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
function addFacetValue(target: Set<string>, value: unknown): void {
|
|
49
94
|
if (value === null || value === undefined || value === "") return;
|
|
50
95
|
target.add(String(value));
|
|
@@ -68,6 +113,7 @@ export function resolveColumnOptions({
|
|
|
68
113
|
if (!source) return [];
|
|
69
114
|
|
|
70
115
|
if (source === "faceted") {
|
|
116
|
+
warnFacetedUnderManualMode(table, column.id);
|
|
71
117
|
const values = new Set<string>();
|
|
72
118
|
for (const key of column.getFacetedUniqueValues().keys()) {
|
|
73
119
|
if (Array.isArray(key)) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ReadonlyStore, Store } from "@tanstack/store";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Timing repair for TanStack's controlled-state sync.
|
|
5
|
+
*
|
|
6
|
+
* `useTable` calls `table.setOptions` from its render body, and that syncs
|
|
7
|
+
* `options.state` into the table's atoms. An atom write publishes the store
|
|
8
|
+
* synchronously, so every component subscribed to `table.store` schedules a
|
|
9
|
+
* setState while the consumer's component is still rendering - which React
|
|
10
|
+
* reports as "Cannot update a component (X) while rendering a different
|
|
11
|
+
* component (Y)", pointing at the consumer's component rather than at the
|
|
12
|
+
* grid.
|
|
13
|
+
*
|
|
14
|
+
* The write has to stay where it is: the atoms must hold the controlled value
|
|
15
|
+
* before the table builds its row models for that render. Only the
|
|
16
|
+
* notification is early, so it moves to a microtask. `getSnapshot` already
|
|
17
|
+
* returns the new value, so a component reads the same state either way; the
|
|
18
|
+
* subscription is only what schedules a re-render for the components the
|
|
19
|
+
* consumer's own render does not reach.
|
|
20
|
+
*
|
|
21
|
+
* Scope and assumptions. Only `table.store` is patched: a subscription made
|
|
22
|
+
* on `table.atoms.*` or `table.baseAtoms.*` keeps the synchronous timing.
|
|
23
|
+
* The patch leans on three table-core 9.0.0-beta.21 internals - the store is
|
|
24
|
+
* created once in `constructTable`, `useTable` returns a shallow copy that
|
|
25
|
+
* shares it, and the sync runs inside `setOptions` during render. The
|
|
26
|
+
* publish test in controlledState.test.tsx fails loudly if any of them
|
|
27
|
+
* moves; it is the guard for this module, not a redundant check. The
|
|
28
|
+
* intended end state - owning the controlled slices through `options.atoms`,
|
|
29
|
+
* which removes the render-time sync entirely - is in the backlog.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
type Observer = ((value: unknown) => void) | { next?: (value: unknown) => void };
|
|
33
|
+
|
|
34
|
+
type Subscribe = (observer: Observer) => { unsubscribe: () => void };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True while `useTable` is syncing controlled state inside a render pass.
|
|
38
|
+
* Process-global, so while one grid is inside its `useTable` call a publish
|
|
39
|
+
* on any patched store is deferred too - harmless, since nothing else runs
|
|
40
|
+
* during a synchronous render pass.
|
|
41
|
+
*/
|
|
42
|
+
let syncing = false;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Called immediately before the `useTable` call that performs the sync.
|
|
46
|
+
*
|
|
47
|
+
* The microtask is the backstop that bounds the flag to the current task: if
|
|
48
|
+
* `useTable` throws and an error boundary unmounts the grid, nothing after
|
|
49
|
+
* the call runs, and without it every patched store would keep deferring its
|
|
50
|
+
* notifications for the life of the page.
|
|
51
|
+
*/
|
|
52
|
+
export function beginControlledStateSync(): void {
|
|
53
|
+
syncing = true;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
syncing = false;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Called immediately after it - the prompt clear; correctness rests on the
|
|
61
|
+
* backstop above. Not a `finally` around the call, because hooks inside
|
|
62
|
+
* `try` opt the whole hook out of the React Compiler.
|
|
63
|
+
*/
|
|
64
|
+
export function endControlledStateSync(): void {
|
|
65
|
+
syncing = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const patched = new WeakSet<object>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wraps a table store's `subscribe` so notifications raised during the sync
|
|
72
|
+
* are delivered in a microtask. Idempotent, and installed on the store object
|
|
73
|
+
* itself rather than on a copy of the table - rows hold the original, so
|
|
74
|
+
* `row.table.store` has to be the patched one.
|
|
75
|
+
*/
|
|
76
|
+
export function deferControlledStateSyncPublishes<TState>(
|
|
77
|
+
store: Store<TState> | ReadonlyStore<TState>,
|
|
78
|
+
): void {
|
|
79
|
+
if (patched.has(store)) return;
|
|
80
|
+
patched.add(store);
|
|
81
|
+
|
|
82
|
+
const subscribe = store.subscribe.bind(store) as Subscribe;
|
|
83
|
+
|
|
84
|
+
(store as unknown as { subscribe: Subscribe }).subscribe = (observer) => {
|
|
85
|
+
const next =
|
|
86
|
+
typeof observer === "function" ? observer : observer.next?.bind(observer);
|
|
87
|
+
if (next === undefined) return subscribe(observer);
|
|
88
|
+
|
|
89
|
+
let live = true;
|
|
90
|
+
const subscription = subscribe((value) => {
|
|
91
|
+
if (!syncing) {
|
|
92
|
+
next(value);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
// The subscriber may have unmounted between the write and the flush.
|
|
97
|
+
if (live) next(value);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
unsubscribe: () => {
|
|
103
|
+
live = false;
|
|
104
|
+
subscription.unsubscribe();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -707,11 +707,26 @@ export type TMDataGridEditApi<
|
|
|
707
707
|
) => Promise<TMDataGridAddRowsResult>;
|
|
708
708
|
/**
|
|
709
709
|
* Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
710
|
+
* mark in `deletedRowIds` - the row renders struck through until
|
|
711
|
+
* `saveDrafts` reports it. Idempotent: deleting a marked row again leaves
|
|
712
|
+
* it marked, and {@link restoreRow} is the undo. On an entry row,
|
|
713
|
+
* committed or not, it just discards the entry; an id the grid does not
|
|
714
|
+
* know is a no-op.
|
|
713
715
|
*/
|
|
714
716
|
deleteRow: (rowId: string) => void;
|
|
717
|
+
/**
|
|
718
|
+
* {@link deleteRow} for several rows in one call - one notification for
|
|
719
|
+
* the batch, for a bulk action over a selection. Because `deleteRow` is
|
|
720
|
+
* idempotent and ignores unknown ids, the list may be passed exactly as
|
|
721
|
+
* the selection stands - already-marked rows stay marked, duplicates and
|
|
722
|
+
* stale ids do nothing.
|
|
723
|
+
*/
|
|
724
|
+
deleteRows: (rowIds: ReadonlyArray<string>) => void;
|
|
725
|
+
/**
|
|
726
|
+
* Removes a row's deletion mark - the lane's Restore. A no-op on a row
|
|
727
|
+
* that is not marked, and outside `editing.draft`, where no marks exist.
|
|
728
|
+
*/
|
|
729
|
+
restoreRow: (rowId: string) => void;
|
|
715
730
|
/** Whether delete chrome makes sense - the lane's trash gate. */
|
|
716
731
|
canDeleteRows: () => boolean;
|
|
717
732
|
};
|
|
@@ -875,6 +890,7 @@ export function createEditEngine(
|
|
|
875
890
|
};
|
|
876
891
|
const forms = new Map<string, FormEntry>();
|
|
877
892
|
let newRowCounter = 0;
|
|
893
|
+
const NEW_ROW_ID_PREFIX = "__new__";
|
|
878
894
|
/** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
|
|
879
895
|
let savingDrafts = false;
|
|
880
896
|
|
|
@@ -1428,6 +1444,11 @@ export function createEditEngine(
|
|
|
1428
1444
|
} finally {
|
|
1429
1445
|
entry.pendingCommit = null;
|
|
1430
1446
|
}
|
|
1447
|
+
// Dropped while the submit was in flight - cancel or deleteRow won the
|
|
1448
|
+
// race. The form is gone, so there is nothing to park or drop; marking
|
|
1449
|
+
// the row committed now would plant an id in `committedRowIds` that no
|
|
1450
|
+
// save or discard could ever clear.
|
|
1451
|
+
if (forms.get(rowId) !== entry) return true;
|
|
1431
1452
|
if (!entry.lastSubmitOk) {
|
|
1432
1453
|
// Snapshot before the caller closes the editor: the field errors go
|
|
1433
1454
|
// with it, and the row is about to be left carrying them.
|
|
@@ -1532,7 +1553,7 @@ export function createEditEngine(
|
|
|
1532
1553
|
|
|
1533
1554
|
const addRow = (values?: TMDataGridRowData): string => {
|
|
1534
1555
|
newRowCounter += 1;
|
|
1535
|
-
const tempId =
|
|
1556
|
+
const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
|
|
1536
1557
|
createForm(tempId, seedNewRow(values), true);
|
|
1537
1558
|
store.setState((prev) => ({
|
|
1538
1559
|
...prev,
|
|
@@ -1564,7 +1585,7 @@ export function createEditEngine(
|
|
|
1564
1585
|
batch(() => {
|
|
1565
1586
|
for (const values of rows) {
|
|
1566
1587
|
newRowCounter += 1;
|
|
1567
|
-
const tempId =
|
|
1588
|
+
const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
|
|
1568
1589
|
createForm(tempId, seedNewRow(values), true);
|
|
1569
1590
|
tempIds.push(tempId);
|
|
1570
1591
|
}
|
|
@@ -1599,13 +1620,26 @@ export function createEditEngine(
|
|
|
1599
1620
|
}
|
|
1600
1621
|
const context = getContext();
|
|
1601
1622
|
if (context.draft) {
|
|
1602
|
-
//
|
|
1603
|
-
store.setState((prev) =>
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1623
|
+
// Idempotent: a marked row stays marked - `restoreRow` is the undo.
|
|
1624
|
+
store.setState((prev) => {
|
|
1625
|
+
if (prev.deletedRowIds.includes(rowId)) return prev;
|
|
1626
|
+
// Only a consumer row can be marked. A deletion mark is what
|
|
1627
|
+
// `saveDrafts` reports to the server, so an id it cannot act on -
|
|
1628
|
+
// an engine temp id, a record gone from `data`, an id the grid
|
|
1629
|
+
// never knew - must not live on as a mark inflating the draft
|
|
1630
|
+
// count. Entry rows are dropped above, never marked; the prefix
|
|
1631
|
+
// check also catches one already dropped that a stale selection or
|
|
1632
|
+
// a double-fired handler names again, while the table's data still
|
|
1633
|
+
// shows it for one render. The core model, so a filtered-out row
|
|
1634
|
+
// still takes its mark.
|
|
1635
|
+
if (
|
|
1636
|
+
rowId.startsWith(NEW_ROW_ID_PREFIX) ||
|
|
1637
|
+
!(rowId in context.table.getCoreRowModel().rowsById)
|
|
1638
|
+
) {
|
|
1639
|
+
return prev;
|
|
1640
|
+
}
|
|
1641
|
+
return { ...prev, deletedRowIds: [...prev.deletedRowIds, rowId] };
|
|
1642
|
+
});
|
|
1609
1643
|
return;
|
|
1610
1644
|
}
|
|
1611
1645
|
const row = getRow(rowId);
|
|
@@ -1614,6 +1648,26 @@ export function createEditEngine(
|
|
|
1614
1648
|
void context.onRowDelete?.({ rowId, row });
|
|
1615
1649
|
};
|
|
1616
1650
|
|
|
1651
|
+
const deleteRows = (rowIds: ReadonlyArray<string>) => {
|
|
1652
|
+
// One notification for the batch - each id still goes through
|
|
1653
|
+
// `deleteRow`, so entry rows drop and everything else marks or no-ops
|
|
1654
|
+
// by the same rules.
|
|
1655
|
+
batch(() => {
|
|
1656
|
+
for (const rowId of rowIds) deleteRow(rowId);
|
|
1657
|
+
});
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const restoreRow = (rowId: string) => {
|
|
1661
|
+
store.setState((prev) =>
|
|
1662
|
+
prev.deletedRowIds.includes(rowId)
|
|
1663
|
+
? {
|
|
1664
|
+
...prev,
|
|
1665
|
+
deletedRowIds: prev.deletedRowIds.filter((id) => id !== rowId),
|
|
1666
|
+
}
|
|
1667
|
+
: prev,
|
|
1668
|
+
);
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1617
1671
|
const canDeleteRows = (): boolean => {
|
|
1618
1672
|
const context = getContext();
|
|
1619
1673
|
if (context.draft) {
|
|
@@ -1903,6 +1957,8 @@ export function createEditEngine(
|
|
|
1903
1957
|
addRow,
|
|
1904
1958
|
addRows,
|
|
1905
1959
|
deleteRow,
|
|
1960
|
+
deleteRows,
|
|
1961
|
+
restoreRow,
|
|
1906
1962
|
canDeleteRows,
|
|
1907
1963
|
};
|
|
1908
1964
|
}
|
|
@@ -34,8 +34,36 @@ export type TMDataGridFilterControlArgs = {
|
|
|
34
34
|
options: ReadonlyArray<TMDataGridOption>;
|
|
35
35
|
size: TMDataGridSize;
|
|
36
36
|
labels: TMDataGridLabels;
|
|
37
|
+
/**
|
|
38
|
+
* How much room the control has, and whether it names itself. The same
|
|
39
|
+
* vocabulary as `TMDataGrid.FilterPanel`'s own `layout` prop, plus the one
|
|
40
|
+
* value only a header cell can be in.
|
|
41
|
+
*
|
|
42
|
+
* | Layout | Where | Field |
|
|
43
|
+
* | --- | --- | --- |
|
|
44
|
+
* | `"row"` | A filter row laid out side by side | Labelled, fixed width |
|
|
45
|
+
* | `"stacked"` | A filter row in a narrow host - the sidebar | Labelled, full width |
|
|
46
|
+
* | `"header"` | One header cell, under `filters.inHeader` | `aria-label`, full width |
|
|
47
|
+
*
|
|
48
|
+
* Every built-in control honours it. A custom control that ignores it still
|
|
49
|
+
* works - it will simply look the same everywhere.
|
|
50
|
+
*/
|
|
51
|
+
layout: TMDataGridFilterControlLayout;
|
|
37
52
|
};
|
|
38
53
|
|
|
54
|
+
/** How much room a filter control has. See `layout`. */
|
|
55
|
+
export type TMDataGridFilterControlLayout = "row" | "stacked" | "header";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The two a filter *panel* can be in - {@link TMDataGridFilterControlLayout}
|
|
59
|
+
* without the header cell, which is not a panel. `TMDataGrid.FilterPanel`'s
|
|
60
|
+
* `layout` prop.
|
|
61
|
+
*/
|
|
62
|
+
export type TMDataGridFilterPanelLayout = Exclude<
|
|
63
|
+
TMDataGridFilterControlLayout,
|
|
64
|
+
"header"
|
|
65
|
+
>;
|
|
66
|
+
|
|
39
67
|
/**
|
|
40
68
|
* `meta.filter.control` - replaces the built-in value control for this column.
|
|
41
69
|
* Rendered as JSX, never invoked as a bare function, so hooks are legal
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ColumnFiltersState,
|
|
3
|
+
Row,
|
|
4
|
+
RowData,
|
|
5
|
+
TableFeatures,
|
|
6
|
+
} from "@tanstack/react-table";
|
|
7
|
+
import type { TMDataGridTable } from "../useTMDataGrid";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* The value shape stored in `columnFilters` for every TMDataGrid column.
|
|
@@ -210,6 +216,25 @@ export function isTMDataGridFilterValue(
|
|
|
210
216
|
);
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The three value shapes an operator can take. A set is not a range, even
|
|
221
|
+
* though both are arrays.
|
|
222
|
+
*/
|
|
223
|
+
export type TMDataGridFilterValueShape = "scalar" | "set" | "range";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Which shape an operator's value takes. A typed value survives an operator or
|
|
227
|
+
* column change only within its shape, which is the rule both the panel and
|
|
228
|
+
* the header controls use when the operator changes.
|
|
229
|
+
*/
|
|
230
|
+
export function filterValueShape(
|
|
231
|
+
operator: TMDataGridFilterOperator,
|
|
232
|
+
): TMDataGridFilterValueShape {
|
|
233
|
+
if (operatorTakesArrayValue(operator)) return "set";
|
|
234
|
+
if (operatorTakesRangeValue(operator)) return "range";
|
|
235
|
+
return "scalar";
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
/**
|
|
214
239
|
* A filter only narrows the row set once it has something to compare against.
|
|
215
240
|
* Half-typed filters stay in state (so the panel keeps rendering their row) but
|
|
@@ -225,6 +250,44 @@ export function isFilterActive(value: unknown): boolean {
|
|
|
225
250
|
: typeof value.value === "string" && value.value.trim() !== "";
|
|
226
251
|
}
|
|
227
252
|
|
|
253
|
+
/** One column's filter, typed - what `columnFilters` holds per entry. */
|
|
254
|
+
export type TMDataGridColumnFilter = {
|
|
255
|
+
id: string;
|
|
256
|
+
value: TMDataGridFilterValue;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The column filters that are actually narrowing the grid, typed.
|
|
261
|
+
*
|
|
262
|
+
* `ColumnFiltersState` types `value` as `unknown`, so the first line of a
|
|
263
|
+
* server-side mapping layer is otherwise a cast back to the shape the grid
|
|
264
|
+
* itself wrote, wrapped in the same "drop the half-typed ones" filter every
|
|
265
|
+
* consumer writes:
|
|
266
|
+
*
|
|
267
|
+
* ```ts
|
|
268
|
+
* const predicates = activeColumnFilters(table).map((filter) =>
|
|
269
|
+
* toPredicate(filter.id, filter.value),
|
|
270
|
+
* );
|
|
271
|
+
* ```
|
|
272
|
+
*
|
|
273
|
+
* Takes the table, or a `columnFilters` array where the consumer owns the
|
|
274
|
+
* slice. Reading it from the table reads the current value and does not
|
|
275
|
+
* subscribe; inside a component, subscribe to `columnFilters` the way the
|
|
276
|
+
* grid's own chrome does.
|
|
277
|
+
*/
|
|
278
|
+
export function activeColumnFilters<TData extends RowData>(
|
|
279
|
+
source: TMDataGridTable<TData> | ColumnFiltersState,
|
|
280
|
+
): Array<TMDataGridColumnFilter> {
|
|
281
|
+
const columnFilters = Array.isArray(source)
|
|
282
|
+
? source
|
|
283
|
+
: source.store.state.columnFilters;
|
|
284
|
+
return columnFilters.flatMap((entry) =>
|
|
285
|
+
isTMDataGridFilterValue(entry.value) && isFilterActive(entry.value)
|
|
286
|
+
? [{ id: entry.id, value: entry.value }]
|
|
287
|
+
: [],
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
228
291
|
/**
|
|
229
292
|
* One-line description of a single filter, as shown on a filter pill.
|
|
230
293
|
*
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the grid puts its filter controls.
|
|
3
|
+
*
|
|
4
|
+
* | Surface | Where it renders |
|
|
5
|
+
* | --- | --- |
|
|
6
|
+
* | `"popup"` | Floating over the first body rows, under the header |
|
|
7
|
+
* | `"sidebar"` | Beside the table, inside the grid frame |
|
|
8
|
+
* | `"none"` | Nowhere - the grid renders no panel of its own |
|
|
9
|
+
*
|
|
10
|
+
* Header filters are not one of these: they are a second row of controls in
|
|
11
|
+
* the header, always visible, and they coexist with any of the three. See
|
|
12
|
+
* {@link TMDataGridFiltersOptions.inHeader}.
|
|
13
|
+
*/
|
|
14
|
+
export type TMDataGridFilterSurface = "popup" | "sidebar" | "none";
|
|
15
|
+
|
|
16
|
+
/** Which side of the table the sidebar surface renders on. */
|
|
17
|
+
export type TMDataGridFilterSidebarSide = "left" | "right";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `filters` on `useTMDataGrid` - everything about where the filter controls
|
|
21
|
+
* are, as opposed to what they do.
|
|
22
|
+
*
|
|
23
|
+
* Named for the option key, the way `editing` has `TMDataGridEditingOptions`.
|
|
24
|
+
* Not to be confused with `TMDataGridColumnFilterOptions`, which is one
|
|
25
|
+
* column's `meta.filter`.
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* useTMDataGrid({ data, columns, filters: { surface: "sidebar" } });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export type TMDataGridFiltersOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Which surface `TMDataGrid.Table` renders and `TMDataGrid.FilterButton`
|
|
34
|
+
* toggles. Defaults to `"popup"`.
|
|
35
|
+
*
|
|
36
|
+
* Under `"none"` the table renders no panel and the filter button renders
|
|
37
|
+
* nothing. That is what a grid running header filters alone wants, and it is
|
|
38
|
+
* also what frees a hand-placed `<TMDataGrid.FilterPanel />` to be the only
|
|
39
|
+
* panel on the page - mounted, it is always visible, so drive it off
|
|
40
|
+
* `ui.state.filterPanelOpen` if it belongs behind a control of your own.
|
|
41
|
+
*/
|
|
42
|
+
surface?: TMDataGridFilterSurface;
|
|
43
|
+
/** Which side the `"sidebar"` surface sits on. Defaults to `"right"`. */
|
|
44
|
+
sidebarSide?: TMDataGridFilterSidebarSide;
|
|
45
|
+
/** Width of the `"sidebar"` surface, any CSS length. Defaults to `"280px"`. */
|
|
46
|
+
sidebarWidth?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the popup or the sidebar starts open. Read once, at mount, like
|
|
49
|
+
* `initialState`.
|
|
50
|
+
*
|
|
51
|
+
* Defaults to `true` under `"sidebar"` and `false` everywhere else: a
|
|
52
|
+
* sidebar is a layout choice, so asking for one and getting an empty strip
|
|
53
|
+
* until the funnel is clicked is not what it reads like, while a popup that
|
|
54
|
+
* greets you open is in the way.
|
|
55
|
+
*
|
|
56
|
+
* Under `"none"` it is simply the starting value of
|
|
57
|
+
* `ui.state.filterPanelOpen`, which a control of your own can read.
|
|
58
|
+
*/
|
|
59
|
+
defaultOpen?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* A second header row holding one value control per filterable column,
|
|
62
|
+
* always visible. Off by default.
|
|
63
|
+
*
|
|
64
|
+
* Independent of `surface` - a grid may have header filters and a popup at
|
|
65
|
+
* once. What it does change is the column chrome: the header's funnel
|
|
66
|
+
* indicator and the column menu's "Filter" item both come off, because
|
|
67
|
+
* their only job was to reveal a control that is now already on screen.
|
|
68
|
+
*
|
|
69
|
+
* A header cell has room for a value and an operator button, not for the
|
|
70
|
+
* panel's column / operator / value triple. Everything else about a filter
|
|
71
|
+
* is unchanged - the same operators, the same `meta.filter.control`, the
|
|
72
|
+
* same `columnFilters` state.
|
|
73
|
+
*/
|
|
74
|
+
inHeader?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** {@link TMDataGridFiltersOptions} with every default filled in. */
|
|
78
|
+
export type TMDataGridFiltersSettings = Required<TMDataGridFiltersOptions>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fills the defaults in. Field by field rather than by spreading, so an
|
|
82
|
+
* explicit `undefined` - which is what destructuring an absent option group
|
|
83
|
+
* hands over - reads as "not set" rather than overwriting the default with it.
|
|
84
|
+
*
|
|
85
|
+
* `defaultOpen` is the one default that is not a constant: it follows the
|
|
86
|
+
* surface, so there is no flat table of defaults to export.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveFilterOptions(
|
|
89
|
+
options: TMDataGridFiltersOptions = {},
|
|
90
|
+
): TMDataGridFiltersSettings {
|
|
91
|
+
const surface = options.surface ?? "popup";
|
|
92
|
+
return {
|
|
93
|
+
surface,
|
|
94
|
+
sidebarSide: options.sidebarSide ?? "right",
|
|
95
|
+
sidebarWidth: options.sidebarWidth ?? "280px",
|
|
96
|
+
defaultOpen: options.defaultOpen ?? surface === "sidebar",
|
|
97
|
+
inHeader: options.inHeader ?? false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -62,6 +62,8 @@ export type TMDataGridLabels = {
|
|
|
62
62
|
clearAllFilters: string;
|
|
63
63
|
closeFilters: string;
|
|
64
64
|
removeFilter: string;
|
|
65
|
+
/** Names the operator button in a column's header filter control. */
|
|
66
|
+
filterOperatorFor: (column: string) => string;
|
|
65
67
|
|
|
66
68
|
// Filter pills
|
|
67
69
|
activeFilters: string;
|
|
@@ -89,6 +91,8 @@ export type TMDataGridLabels = {
|
|
|
89
91
|
// Footer / pager
|
|
90
92
|
rowsPerPage: string;
|
|
91
93
|
pageRange: (args: { from: number; to: number; total: number }) => string;
|
|
94
|
+
/** `pageCount` is `-1` when a manual grid declares an unknown total. */
|
|
95
|
+
pageNumber: (args: { page: number; pageCount: number }) => string;
|
|
92
96
|
groupedAllRows: (total: number) => string;
|
|
93
97
|
pagingSuspendedHint: string;
|
|
94
98
|
previousPage: string;
|
|
@@ -211,6 +215,7 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
|
|
|
211
215
|
clearAllFilters: "Clear all",
|
|
212
216
|
closeFilters: "Close filters",
|
|
213
217
|
removeFilter: "Remove filter",
|
|
218
|
+
filterOperatorFor: (column) => `${column} filter operator`,
|
|
214
219
|
|
|
215
220
|
activeFilters: "Active filters",
|
|
216
221
|
clearFilter: (column) => `Clear ${column} filter`,
|
|
@@ -235,6 +240,8 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
|
|
|
235
240
|
|
|
236
241
|
rowsPerPage: "Rows per page:",
|
|
237
242
|
pageRange: ({ from, to, total }) => `${from}–${to} of ${total}`,
|
|
243
|
+
pageNumber: ({ page, pageCount }) =>
|
|
244
|
+
pageCount < 0 ? `Page ${page}` : `Page ${page} of ${pageCount}`,
|
|
238
245
|
groupedAllRows: (total) => `Grouped · all ${total} rows`,
|
|
239
246
|
pagingSuspendedHint:
|
|
240
247
|
"Paging is off while the rows are grouped: the whole tree is rendered and virtualized. Ungroup to page again.",
|