@jielga/tmdatagrid 2.0.0-beta.2 → 2.0.0-beta.4
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 +185 -48
- package/dist/index.js +1558 -1341
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/appearance/SKILL.md +1 -1
- package/skills/cell-selection/SKILL.md +1 -1
- package/skills/columns/SKILL.md +1 -1
- package/skills/data/SKILL.md +1 -1
- package/skills/editing/SKILL.md +59 -25
- package/skills/editing/references/common-mistakes.md +10 -8
- package/skills/editing/references/editing-api.md +20 -13
- package/skills/filtering/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/grouping/SKILL.md +1 -1
- package/skills/options/SKILL.md +33 -3
- package/skills/rows/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +1 -1
- package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +10 -7
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +96 -25
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +21 -18
- package/src/tmdatagrid/components/TMDataGridTable.tsx +13 -0
- package/src/tmdatagrid/core/controlledState.ts +179 -0
- package/src/tmdatagrid/core/editEngine.ts +480 -77
- package/src/tmdatagrid/core/labels.ts +8 -1
- package/src/tmdatagrid/core/labelsSv.ts +2 -0
- package/src/tmdatagrid/index.ts +5 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +154 -22
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
import { Button, Group } from "@mantine/core";
|
|
1
|
+
import { Button, Group, Text } from "@mantine/core";
|
|
2
2
|
import { useSelector } from "@tanstack/react-store";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* How many rows
|
|
8
|
-
*
|
|
7
|
+
* How many rows the draft store holds - committed edits, committed entry
|
|
8
|
+
* rows and deletion marks. This is what Save sends, so it is what Save
|
|
9
|
+
* counts: a row the user is still typing into is not in here.
|
|
9
10
|
*/
|
|
10
|
-
function
|
|
11
|
+
function useDraftCount(): number {
|
|
12
|
+
const { edit } = useTMDataGridContext();
|
|
13
|
+
return useSelector(
|
|
14
|
+
edit.store,
|
|
15
|
+
(state) =>
|
|
16
|
+
state.committedRowIds.length +
|
|
17
|
+
state.newRows.filter((newRow) => newRow.committed).length +
|
|
18
|
+
state.deletedRowIds.length,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many rows are still open - edited or entered but not committed, so not
|
|
24
|
+
* part of the save. Surfaced beside Save so an uncommitted row is visible
|
|
25
|
+
* rather than silently left behind.
|
|
26
|
+
*/
|
|
27
|
+
function useOpenCount(): number {
|
|
11
28
|
const { edit } = useTMDataGridContext();
|
|
12
29
|
return useSelector(
|
|
13
30
|
edit.store,
|
|
14
31
|
(state) =>
|
|
15
32
|
state.openRowIds.filter(
|
|
16
33
|
(rowId) =>
|
|
17
|
-
state.
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
!state.committedRowIds.includes(rowId) &&
|
|
35
|
+
!state.newRows.some(
|
|
36
|
+
(newRow) => newRow.tempId === rowId && newRow.committed,
|
|
37
|
+
) &&
|
|
38
|
+
(state.newRows.some((newRow) => newRow.tempId === rowId) ||
|
|
39
|
+
(state.rows[rowId]?.dirtyFields.length ?? 0) > 0),
|
|
40
|
+
).length,
|
|
20
41
|
);
|
|
21
42
|
}
|
|
22
43
|
|
|
@@ -28,7 +49,7 @@ function usePendingCount(): number {
|
|
|
28
49
|
|
|
29
50
|
function EditSaveButton() {
|
|
30
51
|
const { edit, labels } = useTMDataGridContext();
|
|
31
|
-
const
|
|
52
|
+
const draftCount = useDraftCount();
|
|
32
53
|
const isSubmitting = useSelector(edit.store, (state) =>
|
|
33
54
|
state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
|
|
34
55
|
);
|
|
@@ -36,19 +57,39 @@ function EditSaveButton() {
|
|
|
36
57
|
return (
|
|
37
58
|
<Button
|
|
38
59
|
size="compact-sm"
|
|
39
|
-
disabled={
|
|
60
|
+
disabled={draftCount === 0}
|
|
40
61
|
loading={isSubmitting}
|
|
41
62
|
data-dg-part="save-all"
|
|
42
|
-
|
|
63
|
+
data-draft-count={draftCount}
|
|
64
|
+
onClick={() => void edit.saveDrafts()}
|
|
43
65
|
>
|
|
44
|
-
{labels.saveAllEdits(
|
|
66
|
+
{labels.saveAllEdits(draftCount)}
|
|
45
67
|
</Button>
|
|
46
68
|
);
|
|
47
69
|
}
|
|
48
70
|
|
|
71
|
+
/** The count of rows left open, or nothing while every row is decided. */
|
|
72
|
+
function EditOpenRowsNote() {
|
|
73
|
+
const { labels } = useTMDataGridContext();
|
|
74
|
+
const openCount = useOpenCount();
|
|
75
|
+
if (openCount === 0) return null;
|
|
76
|
+
return (
|
|
77
|
+
<Text
|
|
78
|
+
size="xs"
|
|
79
|
+
c="dimmed"
|
|
80
|
+
data-dg-part="open-rows-note"
|
|
81
|
+
data-open-count={openCount}
|
|
82
|
+
>
|
|
83
|
+
{labels.editRowsStillOpen(openCount)}
|
|
84
|
+
</Text>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
49
88
|
function EditDiscardButton() {
|
|
50
89
|
const { edit, labels } = useTMDataGridContext();
|
|
51
|
-
|
|
90
|
+
// Discard drops everything the grid is holding, open rows included, so it
|
|
91
|
+
// stays live while anything is uncommitted.
|
|
92
|
+
const pendingCount = useDraftCount() + useOpenCount();
|
|
52
93
|
|
|
53
94
|
return (
|
|
54
95
|
<Button
|
|
@@ -67,11 +108,25 @@ function EditDiscardButton() {
|
|
|
67
108
|
const EDIT_ACTIONS_CONTROLS: TMDataGridEditActionsControls = {
|
|
68
109
|
Save: EditSaveButton,
|
|
69
110
|
Discard: EditDiscardButton,
|
|
111
|
+
OpenRowsNote: EditOpenRowsNote,
|
|
70
112
|
};
|
|
71
113
|
|
|
72
114
|
/** What the edit chrome is showing. */
|
|
73
115
|
export type TMDataGridEditActionsState = {
|
|
74
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Rows in the draft store, which is what Save sends: committed edits,
|
|
118
|
+
* committed entry rows and deletion marks.
|
|
119
|
+
*/
|
|
120
|
+
draftCount: number;
|
|
121
|
+
/**
|
|
122
|
+
* Rows still open - edited or entered but not committed, so not part of
|
|
123
|
+
* the save. They stay open across a save.
|
|
124
|
+
*/
|
|
125
|
+
openCount: number;
|
|
126
|
+
/**
|
|
127
|
+
* @deprecated Was "everything uncommitted", which Save no longer sends.
|
|
128
|
+
* Reads as `draftCount + openCount`; use whichever you meant.
|
|
129
|
+
*/
|
|
75
130
|
pendingCount: number;
|
|
76
131
|
/** Whether a submit is in flight. */
|
|
77
132
|
isSubmitting: boolean;
|
|
@@ -79,18 +134,22 @@ export type TMDataGridEditActionsState = {
|
|
|
79
134
|
|
|
80
135
|
/** What the edit chrome can do. */
|
|
81
136
|
export type TMDataGridEditActionsActions = {
|
|
82
|
-
/**
|
|
137
|
+
/** Saves the draft store. Open rows are left alone. */
|
|
83
138
|
save: () => Promise<boolean>;
|
|
84
|
-
/**
|
|
139
|
+
/** Submits every open row, committing the ones that validate. */
|
|
140
|
+
commitAll: () => Promise<boolean>;
|
|
141
|
+
/** Drops everything - open form state and the draft store alike. */
|
|
85
142
|
discard: () => void;
|
|
86
143
|
};
|
|
87
144
|
|
|
88
145
|
/** The pre-bound pieces of the built-in edit chrome. */
|
|
89
146
|
export type TMDataGridEditActionsControls = {
|
|
90
|
-
/** Save, with the
|
|
147
|
+
/** Save, with the draft count, disabled while the draft store is empty. */
|
|
91
148
|
Save: () => ReactNode;
|
|
92
149
|
/** Discard, disabled while nothing is pending. */
|
|
93
150
|
Discard: () => ReactNode;
|
|
151
|
+
/** The "N rows still being edited" note, or nothing while there are none. */
|
|
152
|
+
OpenRowsNote: () => ReactNode;
|
|
94
153
|
};
|
|
95
154
|
|
|
96
155
|
/** What {@link TMDataGridEditActionsProps.renderActions} is handed. */
|
|
@@ -108,7 +167,8 @@ export type TMDataGridEditActionsProps = {
|
|
|
108
167
|
* <TMDataGrid.EditActions
|
|
109
168
|
* renderActions={({ state, Controls }) => (
|
|
110
169
|
* <Group>
|
|
111
|
-
* {state.
|
|
170
|
+
* {state.draftCount > 0 && <Badge>{state.draftCount}</Badge>}
|
|
171
|
+
* <Controls.OpenRowsNote />
|
|
112
172
|
* <Controls.Save />
|
|
113
173
|
* <Controls.Discard />
|
|
114
174
|
* </Group>
|
|
@@ -120,11 +180,11 @@ export type TMDataGridEditActionsProps = {
|
|
|
120
180
|
};
|
|
121
181
|
|
|
122
182
|
/**
|
|
123
|
-
* Draft mode's toolbar chrome: Save with the
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* nothing while editing is off.
|
|
183
|
+
* Draft mode's toolbar chrome: Save with the draft-store count, Discard, and
|
|
184
|
+
* a note counting rows still open. Save sends the draft store and leaves open
|
|
185
|
+
* rows alone, so it greys out while nothing is committed however much is
|
|
186
|
+
* being typed - the note is what makes those rows visible. Works under any
|
|
187
|
+
* `editing.mode` and renders nothing while editing is off.
|
|
128
188
|
*
|
|
129
189
|
* ```tsx
|
|
130
190
|
* <TMDataGrid.Toolbar>
|
|
@@ -138,7 +198,8 @@ export function TMDataGridEditActions({
|
|
|
138
198
|
renderActions,
|
|
139
199
|
}: TMDataGridEditActionsProps = {}) {
|
|
140
200
|
const { edit, features } = useTMDataGridContext();
|
|
141
|
-
const
|
|
201
|
+
const draftCount = useDraftCount();
|
|
202
|
+
const openCount = useOpenCount();
|
|
142
203
|
const isSubmitting = useSelector(edit.store, (state) =>
|
|
143
204
|
state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
|
|
144
205
|
);
|
|
@@ -147,14 +208,24 @@ export function TMDataGridEditActions({
|
|
|
147
208
|
|
|
148
209
|
if (renderActions) {
|
|
149
210
|
return renderActions({
|
|
150
|
-
state: {
|
|
151
|
-
|
|
211
|
+
state: {
|
|
212
|
+
draftCount,
|
|
213
|
+
openCount,
|
|
214
|
+
pendingCount: draftCount + openCount,
|
|
215
|
+
isSubmitting,
|
|
216
|
+
},
|
|
217
|
+
actions: {
|
|
218
|
+
save: () => edit.saveDrafts(),
|
|
219
|
+
commitAll: () => edit.commitAll(),
|
|
220
|
+
discard: () => edit.cancelAll(),
|
|
221
|
+
},
|
|
152
222
|
Controls: EDIT_ACTIONS_CONTROLS,
|
|
153
223
|
});
|
|
154
224
|
}
|
|
155
225
|
|
|
156
226
|
return (
|
|
157
227
|
<Group gap="xs" wrap="nowrap">
|
|
228
|
+
<EditOpenRowsNote />
|
|
158
229
|
<EditSaveButton />
|
|
159
230
|
<EditDiscardButton />
|
|
160
231
|
</Group>
|
|
@@ -82,7 +82,7 @@ function EditLaneCell<TData extends RowData>({
|
|
|
82
82
|
const isConfirmedNew = useSelector(
|
|
83
83
|
edit.store,
|
|
84
84
|
(state) =>
|
|
85
|
-
state.newRows.find((newRow) => newRow.tempId === rowId)?.
|
|
85
|
+
state.newRows.find((newRow) => newRow.tempId === rowId)?.committed ===
|
|
86
86
|
true,
|
|
87
87
|
);
|
|
88
88
|
const isMarkedDeleted = useSelector(edit.store, (state) =>
|
|
@@ -67,12 +67,12 @@ export function TMDataGridEntryRows({
|
|
|
67
67
|
}) {
|
|
68
68
|
const { table, edit, features } = useTMDataGridContext();
|
|
69
69
|
const newRows = useSelector(edit.store, (state) => state.newRows);
|
|
70
|
-
// The reopen gesture: `begin` on a
|
|
70
|
+
// The reopen gesture: `begin` on a committed entry row flips it back to
|
|
71
71
|
// editors and names the cell double-clicked - where the caret goes.
|
|
72
72
|
const activeEntry = useSelector(edit.store, (state) =>
|
|
73
73
|
state.active !== null &&
|
|
74
74
|
state.newRows.some(
|
|
75
|
-
(newRow) => newRow.tempId === state.active?.rowId && !newRow.
|
|
75
|
+
(newRow) => newRow.tempId === state.active?.rowId && !newRow.committed,
|
|
76
76
|
)
|
|
77
77
|
? state.active
|
|
78
78
|
: null,
|
|
@@ -109,7 +109,7 @@ export function TMDataGridEntryRows({
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* The caret for a reopen: `begin` on a
|
|
112
|
+
* The caret for a reopen: `begin` on a committed row re-arms its editors
|
|
113
113
|
* and the double-clicked cell's editor should hold the caret, not the
|
|
114
114
|
* row's first. Keyed separately from the add effect above - the row was
|
|
115
115
|
* already focused once when it was added.
|
|
@@ -134,16 +134,16 @@ export function TMDataGridEntryRows({
|
|
|
134
134
|
focusEditorContent(editor);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
//
|
|
137
|
+
// Open (uncommitted) rows keep the seed values frozen at addRow - the live values
|
|
138
138
|
// belong to the forms, which the editors read directly, and this table only
|
|
139
|
-
// provides row and cell identity. A
|
|
139
|
+
// provides row and cell identity. A committed row's cells render values, so
|
|
140
140
|
// there the draft itself is the row; the memo recomputes on every
|
|
141
|
-
//
|
|
141
|
+
// commit/reopen because either flips `newRows`' identity.
|
|
142
142
|
const data = useMemo(
|
|
143
143
|
() =>
|
|
144
144
|
newRows.map(
|
|
145
|
-
({ tempId,
|
|
146
|
-
((
|
|
145
|
+
({ tempId, committed }) =>
|
|
146
|
+
((committed
|
|
147
147
|
? edit.getForm(tempId)?.state.values
|
|
148
148
|
: edit.getForm(tempId)?.options.defaultValues) ??
|
|
149
149
|
{}) as TMDataGridRowData,
|
|
@@ -161,20 +161,20 @@ export function TMDataGridEntryRows({
|
|
|
161
161
|
if (newRows.length === 0) return null;
|
|
162
162
|
|
|
163
163
|
const entryRows = entryTable.getCoreRowModel().rows;
|
|
164
|
-
const
|
|
165
|
-
newRows.map((newRow) => [newRow.tempId, newRow.
|
|
164
|
+
const committedById = new Map(
|
|
165
|
+
newRows.map((newRow) => [newRow.tempId, newRow.committed]),
|
|
166
166
|
);
|
|
167
167
|
// A row being *typed* into is always sticky - it exists nowhere else to
|
|
168
|
-
// scroll back to. A
|
|
168
|
+
// scroll back to. A committed row joins the scrolling flow unless
|
|
169
169
|
// `newRowsSticky` keeps it pinned, so entering many rows cannot fill the
|
|
170
170
|
// viewport with sticky chrome.
|
|
171
171
|
const stickyRows = entryRows.filter(
|
|
172
172
|
(entryRow) =>
|
|
173
|
-
features.editNewRowsSticky ||
|
|
173
|
+
features.editNewRowsSticky || committedById.get(entryRow.id) !== true,
|
|
174
174
|
);
|
|
175
175
|
const flowRows = features.editNewRowsSticky
|
|
176
176
|
? []
|
|
177
|
-
: entryRows.filter((entryRow) =>
|
|
177
|
+
: entryRows.filter((entryRow) => committedById.get(entryRow.id) === true);
|
|
178
178
|
|
|
179
179
|
const renderEntryRow = (
|
|
180
180
|
entryRow: (typeof entryRows)[number],
|
|
@@ -183,7 +183,7 @@ export function TMDataGridEntryRows({
|
|
|
183
183
|
const cellsById = new Map(
|
|
184
184
|
entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
|
|
185
185
|
);
|
|
186
|
-
const
|
|
186
|
+
const committed = committedById.get(entryRow.id) === true;
|
|
187
187
|
return (
|
|
188
188
|
<div
|
|
189
189
|
key={entryRow.id}
|
|
@@ -191,7 +191,10 @@ export function TMDataGridEntryRows({
|
|
|
191
191
|
data-dg-part="entry-row"
|
|
192
192
|
data-row-id={entryRow.id}
|
|
193
193
|
data-new
|
|
194
|
-
data-
|
|
194
|
+
data-committed={committed}
|
|
195
|
+
// The same marker body rows carry once committed, so one selector
|
|
196
|
+
// reaches everything parked in the draft store.
|
|
197
|
+
data-draft={committed}
|
|
195
198
|
className={classes.entryRow}
|
|
196
199
|
>
|
|
197
200
|
{orderedColumns.map((column) => {
|
|
@@ -206,10 +209,10 @@ export function TMDataGridEntryRows({
|
|
|
206
209
|
data-column-id={column.id}
|
|
207
210
|
data-align={getColumnAlign(column)}
|
|
208
211
|
data-control-column={isControlColumn(column.id)}
|
|
209
|
-
// A
|
|
212
|
+
// A committed row re-opens where it is double-clicked, the
|
|
210
213
|
// same gesture a body cell answers.
|
|
211
214
|
onDoubleClick={
|
|
212
|
-
|
|
215
|
+
committed && !isControlColumn(column.id)
|
|
213
216
|
? () =>
|
|
214
217
|
edit.begin({
|
|
215
218
|
rowId: entryRow.id,
|
|
@@ -239,7 +242,7 @@ export function TMDataGridEntryRows({
|
|
|
239
242
|
{cell !== undefined && column.id === EDIT_COLUMN_ID ? (
|
|
240
243
|
// The lane's cell - the entry row's controls.
|
|
241
244
|
flexRender(cell.column.columnDef.cell, cell.getContext())
|
|
242
|
-
) :
|
|
245
|
+
) : committed && cell !== undefined ? (
|
|
243
246
|
// Entered, awaiting Save all: a value row through the
|
|
244
247
|
// columns' own renderers, over the draft the memo above
|
|
245
248
|
// fed this table.
|
|
@@ -750,6 +750,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
750
750
|
(id) => (state.rows[id]?.dirtyFields.length ?? 0) > 0,
|
|
751
751
|
),
|
|
752
752
|
);
|
|
753
|
+
// Rows parked in the draft store, waiting for Save. A separate marker from
|
|
754
|
+
// `data-dirty`, which covers any row being typed into: after a partial save
|
|
755
|
+
// these are what is left, so one selector highlights everything still
|
|
756
|
+
// pending.
|
|
757
|
+
const editDraftRowIds = useSelector(
|
|
758
|
+
edit.store,
|
|
759
|
+
(state) => state.committedRowIds,
|
|
760
|
+
);
|
|
753
761
|
|
|
754
762
|
const { loading, noResultsLabel = labels.noResults } =
|
|
755
763
|
table.options.meta ?? {};
|
|
@@ -2233,6 +2241,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
2233
2241
|
editDirtyRowIds.length > 0 &&
|
|
2234
2242
|
editDirtyRowIds.includes(row.id)
|
|
2235
2243
|
}
|
|
2244
|
+
// Committed into the draft store, waiting for Save.
|
|
2245
|
+
data-draft={
|
|
2246
|
+
editDraftRowIds.length > 0 &&
|
|
2247
|
+
editDraftRowIds.includes(row.id)
|
|
2248
|
+
}
|
|
2236
2249
|
// The menu is anchored to the rowgroup, so Mantine's own
|
|
2237
2250
|
// `data-expanded` lands there rather than on a row. This is
|
|
2238
2251
|
// what says which row the open menu is about.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { TableState } from "@tanstack/react-table";
|
|
2
|
+
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
3
|
+
|
|
4
|
+
type GridState = TableState<TMDataGridFeatures>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Support for TanStack's `state` option (controlled state).
|
|
8
|
+
*
|
|
9
|
+
* table-core re-reads `options.state` on every render (`useTable` calls
|
|
10
|
+
* `setOptions` while rendering) and writes any slice whose value is not
|
|
11
|
+
* identical to the table's current value into the table's atom. The write
|
|
12
|
+
* publishes the store and re-renders the consumer, so a slice object built in
|
|
13
|
+
* the render body causes an infinite render loop.
|
|
14
|
+
*
|
|
15
|
+
* {@link stabilizeControlledState} prevents the loop by forwarding the
|
|
16
|
+
* previous render's value for a slice whose contents are unchanged.
|
|
17
|
+
* {@link findFrozenStateSlices} detects a controlled slice without its
|
|
18
|
+
* `onXChange` callback, which the grid cannot write to.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `onXChange` option that owns each controlled slice - every slice v9
|
|
23
|
+
* defines. `globalFilterFn` is not among them: in this beta it is a table
|
|
24
|
+
* option, not state.
|
|
25
|
+
*/
|
|
26
|
+
export const CONTROLLED_SLICE_HANDLERS = {
|
|
27
|
+
columnFilters: "onColumnFiltersChange",
|
|
28
|
+
columnOrder: "onColumnOrderChange",
|
|
29
|
+
columnPinning: "onColumnPinningChange",
|
|
30
|
+
columnResizing: "onColumnResizingChange",
|
|
31
|
+
columnSizing: "onColumnSizingChange",
|
|
32
|
+
columnVisibility: "onColumnVisibilityChange",
|
|
33
|
+
expanded: "onExpandedChange",
|
|
34
|
+
globalFilter: "onGlobalFilterChange",
|
|
35
|
+
grouping: "onGroupingChange",
|
|
36
|
+
pagination: "onPaginationChange",
|
|
37
|
+
rowPinning: "onRowPinningChange",
|
|
38
|
+
rowSelection: "onRowSelectionChange",
|
|
39
|
+
sorting: "onSortingChange",
|
|
40
|
+
} as const satisfies Partial<Record<keyof GridState, string>>;
|
|
41
|
+
|
|
42
|
+
export type TMDataGridControlledSlice = keyof typeof CONTROLLED_SLICE_HANDLERS;
|
|
43
|
+
|
|
44
|
+
/** The options {@link findFrozenStateSlices} reads - a subset of `TableOptions`. */
|
|
45
|
+
type ControlledOptions = {
|
|
46
|
+
state?: Partial<GridState>;
|
|
47
|
+
/**
|
|
48
|
+
* The v9 way of owning a slice: an atom the table writes through directly.
|
|
49
|
+
* It outranks `state`, so a slice backed by one is never frozen.
|
|
50
|
+
*/
|
|
51
|
+
atoms?: Record<string, unknown>;
|
|
52
|
+
} & Partial<
|
|
53
|
+
Record<(typeof CONTROLLED_SLICE_HANDLERS)[TMDataGridControlledSlice], unknown>
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
const hasOwn = (value: object, key: string): boolean =>
|
|
57
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A plain data object: `Object.prototype` or null prototype. table-core builds
|
|
61
|
+
* its state maps with `Object.create(null)`, so null prototypes must compare
|
|
62
|
+
* as plain objects.
|
|
63
|
+
*/
|
|
64
|
+
function isDataObject(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
if (typeof value !== "object" || value === null) return false;
|
|
66
|
+
const proto = Object.getPrototypeOf(value);
|
|
67
|
+
return proto === Object.prototype || proto === null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Structural equality for state values.
|
|
72
|
+
*
|
|
73
|
+
* `Date`s compare by time; the built-in date filters hold them in filter
|
|
74
|
+
* values. `Map`s and class instances compare by identity, so a controlled
|
|
75
|
+
* slice containing one rebuilt each render reads as changed on every render
|
|
76
|
+
* and re-renders the table. Use primitives, plain objects, arrays or `Date`s
|
|
77
|
+
* in controlled state.
|
|
78
|
+
*/
|
|
79
|
+
export function sameStateValue(a: unknown, b: unknown): boolean {
|
|
80
|
+
if (Object.is(a, b)) return true;
|
|
81
|
+
if (a instanceof Date || b instanceof Date) {
|
|
82
|
+
return (
|
|
83
|
+
a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
87
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
88
|
+
return (
|
|
89
|
+
a.length === b.length &&
|
|
90
|
+
a.every((item, index) => sameStateValue(item, b[index]))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!isDataObject(a) || !isDataObject(b)) return false;
|
|
94
|
+
const keys = Object.keys(a);
|
|
95
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
96
|
+
return keys.every((key) => hasOwn(b, key) && sameStateValue(a[key], b[key]));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Removes keys whose value is `undefined`, as in
|
|
101
|
+
* `state: { sorting: cond ? sorting : undefined }`.
|
|
102
|
+
*
|
|
103
|
+
* TanStack's sync writes every present key into the slice's atom, `undefined`
|
|
104
|
+
* included, which breaks everything that reads the slice. A key set to
|
|
105
|
+
* `undefined` is treated as not controlled: the key is removed and the slice
|
|
106
|
+
* falls back to the table's own state. Identity is preserved when there is
|
|
107
|
+
* nothing to remove; the stabilizer compares by it.
|
|
108
|
+
*/
|
|
109
|
+
export function withoutUndefinedSlices(
|
|
110
|
+
state: Partial<GridState> | undefined,
|
|
111
|
+
): Partial<GridState> | undefined {
|
|
112
|
+
if (state === undefined) return state;
|
|
113
|
+
if (!Object.values(state).includes(undefined)) return state;
|
|
114
|
+
const compacted: Record<string, unknown> = {};
|
|
115
|
+
for (const [key, value] of Object.entries(state)) {
|
|
116
|
+
if (value !== undefined) compacted[key] = value;
|
|
117
|
+
}
|
|
118
|
+
return compacted as Partial<GridState>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns `next` with every slice that structurally equals its counterpart in
|
|
123
|
+
* `previous` replaced by the previous object, so table-core's identity-based
|
|
124
|
+
* sync finds nothing to write. Returns `previous` when nothing changed.
|
|
125
|
+
*/
|
|
126
|
+
export function stabilizeControlledState(
|
|
127
|
+
next: Partial<GridState> | undefined,
|
|
128
|
+
previous: Partial<GridState> | undefined,
|
|
129
|
+
): Partial<GridState> | undefined {
|
|
130
|
+
if (next === undefined || previous === undefined || next === previous) {
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const keys = Object.keys(next);
|
|
135
|
+
const stabilized: Record<string, unknown> = {};
|
|
136
|
+
let changed = keys.length !== Object.keys(previous).length;
|
|
137
|
+
|
|
138
|
+
for (const key of keys) {
|
|
139
|
+
const nextValue = (next as Record<string, unknown>)[key];
|
|
140
|
+
const previousValue = (previous as Record<string, unknown>)[key];
|
|
141
|
+
if (hasOwn(previous, key) && sameStateValue(nextValue, previousValue)) {
|
|
142
|
+
// Unchanged contents: keep the object the table already holds.
|
|
143
|
+
stabilized[key] = previousValue;
|
|
144
|
+
} else {
|
|
145
|
+
stabilized[key] = nextValue;
|
|
146
|
+
changed = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return changed ? (stabilized as Partial<GridState>) : previous;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Controlled slices without a write path: `state.x` is set but no `onXChange`
|
|
155
|
+
* was passed and no external atom owns the slice. TanStack routes every write
|
|
156
|
+
* through the callback, so such a slice cannot change. Reported so the hook
|
|
157
|
+
* can warn in development; `initialState` is the option for a starting value.
|
|
158
|
+
*/
|
|
159
|
+
export function findFrozenStateSlices(
|
|
160
|
+
options: ControlledOptions,
|
|
161
|
+
): Array<{ slice: TMDataGridControlledSlice; handler: string }> {
|
|
162
|
+
const state = options.state;
|
|
163
|
+
if (state === undefined) return [];
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
Object.entries(CONTROLLED_SLICE_HANDLERS) as Array<
|
|
167
|
+
[TMDataGridControlledSlice, string]
|
|
168
|
+
>
|
|
169
|
+
)
|
|
170
|
+
.filter(
|
|
171
|
+
([slice, handler]) =>
|
|
172
|
+
// Undefined-valued keys are removed before the table sees them; the
|
|
173
|
+
// slice is not controlled.
|
|
174
|
+
(state as Record<string, unknown>)[slice] !== undefined &&
|
|
175
|
+
(options as Record<string, unknown>)[handler] === undefined &&
|
|
176
|
+
options.atoms?.[slice] === undefined,
|
|
177
|
+
)
|
|
178
|
+
.map(([slice, handler]) => ({ slice, handler }));
|
|
179
|
+
}
|