@jielga/tmdatagrid 0.5.0 → 1.0.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/README.md +25 -2
- package/dist/index.d.ts +856 -14
- package/dist/index.js +3370 -1546
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.layer.css +1 -0
- package/package.json +12 -2
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/options/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +198 -0
- package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
- package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
- package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
- package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
- package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
- package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
- package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
- package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
- package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
- package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
- package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
- package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
- package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
- package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
- package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
- package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
- package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
- package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
- package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
- package/src/tmdatagrid/components/icons.ts +4 -0
- package/src/tmdatagrid/core/autosize.ts +1 -1
- package/src/tmdatagrid/core/capabilities.ts +37 -1
- package/src/tmdatagrid/core/cellNavigation.ts +41 -0
- package/src/tmdatagrid/core/columnUtils.ts +31 -6
- package/src/tmdatagrid/core/editEngine.ts +857 -0
- package/src/tmdatagrid/core/filterControls.ts +45 -0
- package/src/tmdatagrid/core/filterOperators.ts +72 -4
- package/src/tmdatagrid/core/labels.ts +68 -1
- package/src/tmdatagrid/core/labelsSv.ts +24 -1
- package/src/tmdatagrid/core/matchHighlight.ts +96 -0
- package/src/tmdatagrid/core/persistence.ts +97 -3
- package/src/tmdatagrid/core/quickSearch.ts +144 -0
- package/src/tmdatagrid/core/rowPinning.ts +37 -0
- package/src/tmdatagrid/core/rowSelection.ts +10 -1
- package/src/tmdatagrid/index.ts +51 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +456 -15
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { ActionIcon } from "@mantine/core";
|
|
2
|
+
import { FieldApi } from "@tanstack/react-form";
|
|
3
|
+
import type { Cell, Row } from "@tanstack/react-table";
|
|
4
|
+
import {
|
|
5
|
+
type ComponentType,
|
|
6
|
+
type FocusEvent,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
import classes from "./TMDataGridTable.module.css";
|
|
13
|
+
import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
|
|
14
|
+
import {
|
|
15
|
+
getEditFieldName,
|
|
16
|
+
normalizeFieldValidate,
|
|
17
|
+
type TMDataGridEditField,
|
|
18
|
+
type TMDataGridEditorArgs,
|
|
19
|
+
} from "../core/editEngine";
|
|
20
|
+
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
21
|
+
import { CheckIcon, CloseIcon } from "./icons";
|
|
22
|
+
import { TMDataGridBooleanEditor } from "./editors/TMDataGridBooleanEditor";
|
|
23
|
+
import { TMDataGridDateEditor } from "./editors/TMDataGridDateEditor";
|
|
24
|
+
import { TMDataGridMultiSelectEditor } from "./editors/TMDataGridMultiSelectEditor";
|
|
25
|
+
import { TMDataGridNumberEditor } from "./editors/TMDataGridNumberEditor";
|
|
26
|
+
import { TMDataGridSelectEditor } from "./editors/TMDataGridSelectEditor";
|
|
27
|
+
import { TMDataGridStringEditor } from "./editors/TMDataGridStringEditor";
|
|
28
|
+
|
|
29
|
+
const BUILT_IN_EDITORS: Record<
|
|
30
|
+
string,
|
|
31
|
+
ComponentType<TMDataGridEditorArgs>
|
|
32
|
+
> = {
|
|
33
|
+
string: TMDataGridStringEditor,
|
|
34
|
+
number: TMDataGridNumberEditor,
|
|
35
|
+
boolean: TMDataGridBooleanEditor,
|
|
36
|
+
date: TMDataGridDateEditor,
|
|
37
|
+
select: TMDataGridSelectEditor,
|
|
38
|
+
multiSelect: TMDataGridMultiSelectEditor,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** How an editor closed, for the table to decide where the focus goes. */
|
|
42
|
+
export type TMDataGridCellEditorClose =
|
|
43
|
+
| { committed: true; via: "enter" | "tab" | "shift-tab" | "pick" }
|
|
44
|
+
| { committed: false; via: "escape" }
|
|
45
|
+
/** Batch: the editor closed, the draft stays — Enter parks, Tab moves on. */
|
|
46
|
+
| { committed: false; via: "defer" | "defer-tab" | "defer-shift-tab" };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The host an editing cell mounts — it owns the field, the keys and the blur
|
|
50
|
+
* policy; the editor inside it (a built-in picked by `meta.type`, or the
|
|
51
|
+
* column's `meta.editor`) owns the input.
|
|
52
|
+
*
|
|
53
|
+
* The form outlives this component by design: scrolling the row away
|
|
54
|
+
* unmounts the host, and the form in the engine's map keeps the draft.
|
|
55
|
+
*/
|
|
56
|
+
export function TMDataGridCellEditor({
|
|
57
|
+
cell,
|
|
58
|
+
row,
|
|
59
|
+
takeSeedText,
|
|
60
|
+
autoFocus = true,
|
|
61
|
+
onClose,
|
|
62
|
+
}: {
|
|
63
|
+
cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
64
|
+
row: Row<TMDataGridFeatures, TMDataGridRowData>;
|
|
65
|
+
/** Consumes the pending type-to-edit seed, when typing opened this editor. */
|
|
66
|
+
takeSeedText: () => string | undefined;
|
|
67
|
+
/** In row mode only the first editable cell takes the focus. */
|
|
68
|
+
autoFocus?: boolean;
|
|
69
|
+
/** After the editor closed itself — the table moves the focus. */
|
|
70
|
+
onClose: (args: TMDataGridCellEditorClose) => void;
|
|
71
|
+
}) {
|
|
72
|
+
const { table, edit, features, labels, controlSize } = useTMDataGridContext();
|
|
73
|
+
// Row mode mounts one host per editable cell of the row; every key that
|
|
74
|
+
// commits or cancels acts on the whole row's form either way, but Tab must
|
|
75
|
+
// stay the browser's (it walks the row's inputs) and blur must do nothing
|
|
76
|
+
// (focus moves between this row's own editors).
|
|
77
|
+
const isRowMode = features.editMode === "row";
|
|
78
|
+
const column = cell.column;
|
|
79
|
+
const form = edit.getForm(row.id);
|
|
80
|
+
const fieldName = getEditFieldName(column);
|
|
81
|
+
const [seedText] = useState(() => takeSeedText());
|
|
82
|
+
|
|
83
|
+
// One FieldApi per open editor, over the row's long-lived form. Created
|
|
84
|
+
// imperatively (the documented core usage) because the form was too.
|
|
85
|
+
const [field] = useState<TMDataGridEditField>(
|
|
86
|
+
() =>
|
|
87
|
+
new FieldApi({
|
|
88
|
+
form: form as never,
|
|
89
|
+
name: fieldName as never,
|
|
90
|
+
validators: normalizeFieldValidate(
|
|
91
|
+
column.columnDef.meta?.validate,
|
|
92
|
+
) as never,
|
|
93
|
+
}) as unknown as TMDataGridEditField,
|
|
94
|
+
);
|
|
95
|
+
useEffect(() => field.mount(), [field]);
|
|
96
|
+
|
|
97
|
+
// Type-to-edit: the seed replaces the value, as it does in a spreadsheet.
|
|
98
|
+
// Only for the types where keystrokes are the input.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (seedText === undefined) return;
|
|
101
|
+
const type = column.columnDef.meta?.type ?? "string";
|
|
102
|
+
if (type === "string") {
|
|
103
|
+
field.handleChange(seedText as never);
|
|
104
|
+
} else if (type === "number") {
|
|
105
|
+
const seeded = Number(seedText);
|
|
106
|
+
if (Number.isFinite(seeded)) field.handleChange(seeded as never);
|
|
107
|
+
}
|
|
108
|
+
// Once, at open.
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const closingRef = useRef(false);
|
|
113
|
+
|
|
114
|
+
const commitAndClose = async (
|
|
115
|
+
via: "enter" | "tab" | "shift-tab" | "pick",
|
|
116
|
+
): Promise<boolean> => {
|
|
117
|
+
if (closingRef.current) return false;
|
|
118
|
+
const ok = await edit.commit(row.id);
|
|
119
|
+
if (ok && !closingRef.current) {
|
|
120
|
+
closingRef.current = true;
|
|
121
|
+
onClose({ committed: true, via });
|
|
122
|
+
}
|
|
123
|
+
return ok;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cancelAndClose = () => {
|
|
127
|
+
if (closingRef.current) return;
|
|
128
|
+
closingRef.current = true;
|
|
129
|
+
edit.cancel(row.id);
|
|
130
|
+
onClose({ committed: false, via: "escape" });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Batch: the key closes the editor and leaves the draft for submitAll. */
|
|
134
|
+
const deferAndClose = (via: "defer" | "defer-tab" | "defer-shift-tab") => {
|
|
135
|
+
if (closingRef.current) return;
|
|
136
|
+
closingRef.current = true;
|
|
137
|
+
edit.deactivate();
|
|
138
|
+
onClose({ committed: false, via });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
142
|
+
const isBatch = features.editMode === "batch";
|
|
143
|
+
if (event.key === "Enter") {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
event.stopPropagation();
|
|
146
|
+
// In row mode Enter (and Ctrl+Enter, the documented pair) saves the
|
|
147
|
+
// row; in batch nothing commits until submitAll, so Enter just parks
|
|
148
|
+
// the draft.
|
|
149
|
+
if (isBatch) deferAndClose("defer");
|
|
150
|
+
else void commitAndClose("enter");
|
|
151
|
+
} else if (event.key === "Escape") {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
event.stopPropagation();
|
|
154
|
+
cancelAndClose();
|
|
155
|
+
} else if (event.key === "Tab" && !isRowMode) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
event.stopPropagation();
|
|
158
|
+
if (isBatch) {
|
|
159
|
+
deferAndClose(event.shiftKey ? "defer-shift-tab" : "defer-tab");
|
|
160
|
+
} else {
|
|
161
|
+
void commitAndClose(event.shiftKey ? "shift-tab" : "tab");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Focus left the editor entirely — a click somewhere else. Under `"cell"`
|
|
168
|
+
* that commits (Sheets); a commit blocked by validation keeps the form and
|
|
169
|
+
* its invalid marker, with the editor closed. The confirming modes keep
|
|
170
|
+
* the draft open-but-inactive, waiting for their explicit ✓.
|
|
171
|
+
*/
|
|
172
|
+
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
|
173
|
+
if (closingRef.current) return;
|
|
174
|
+
// Row mode: the row's other editors are outside this host, and focus
|
|
175
|
+
// moving between them is not leaving the edit. Blur means nothing here;
|
|
176
|
+
// the lane's Save/Cancel and the keys end the edit.
|
|
177
|
+
if (isRowMode) return;
|
|
178
|
+
const next = event.relatedTarget;
|
|
179
|
+
if (next instanceof Node && event.currentTarget.contains(next)) return;
|
|
180
|
+
closingRef.current = true;
|
|
181
|
+
if (features.editMode === "cell") {
|
|
182
|
+
void edit.commit(row.id).then((ok) => {
|
|
183
|
+
if (!ok) edit.deactivate();
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
// cellConfirm: the draft stays, dirty-marked, waiting for its ✓.
|
|
187
|
+
edit.deactivate();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (form === undefined || fieldName === null) return null;
|
|
192
|
+
|
|
193
|
+
const args: TMDataGridEditorArgs = {
|
|
194
|
+
field,
|
|
195
|
+
form,
|
|
196
|
+
cell,
|
|
197
|
+
row,
|
|
198
|
+
column,
|
|
199
|
+
table,
|
|
200
|
+
commit: () => commitAndClose("pick"),
|
|
201
|
+
cancel: cancelAndClose,
|
|
202
|
+
size: controlSize,
|
|
203
|
+
autoFocus,
|
|
204
|
+
seedText,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const Editor =
|
|
208
|
+
column.columnDef.meta?.editor ??
|
|
209
|
+
BUILT_IN_EDITORS[column.columnDef.meta?.type ?? "string"] ??
|
|
210
|
+
TMDataGridStringEditor;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
className={classes.cellEditor}
|
|
215
|
+
data-dg-part="editor"
|
|
216
|
+
data-row-id={row.id}
|
|
217
|
+
data-column-id={column.id}
|
|
218
|
+
onKeyDown={handleKeyDown}
|
|
219
|
+
onBlur={handleBlur}
|
|
220
|
+
// The cell underneath listens for selection gestures; a click inside
|
|
221
|
+
// the editor is about the editor.
|
|
222
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
223
|
+
onDoubleClick={(event) => event.stopPropagation()}
|
|
224
|
+
>
|
|
225
|
+
<Editor {...args} />
|
|
226
|
+
{/* cellConfirm's chrome: the draft only commits through the ✓ (or
|
|
227
|
+
Enter), so the pair sits right beside the input. */}
|
|
228
|
+
{features.editMode === "cellConfirm" && (
|
|
229
|
+
<div className={classes.cellEditorActions}>
|
|
230
|
+
<ActionIcon
|
|
231
|
+
size="sm"
|
|
232
|
+
variant="subtle"
|
|
233
|
+
color="green"
|
|
234
|
+
aria-label={labels.confirmEdit}
|
|
235
|
+
data-dg-part="editor-confirm"
|
|
236
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
237
|
+
onClick={() => void commitAndClose("pick")}
|
|
238
|
+
>
|
|
239
|
+
<CheckIcon size={14} stroke={2} />
|
|
240
|
+
</ActionIcon>
|
|
241
|
+
<ActionIcon
|
|
242
|
+
size="sm"
|
|
243
|
+
variant="subtle"
|
|
244
|
+
color="gray"
|
|
245
|
+
aria-label={labels.cancelEdit}
|
|
246
|
+
data-dg-part="editor-cancel"
|
|
247
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
248
|
+
onClick={cancelAndClose}
|
|
249
|
+
>
|
|
250
|
+
<CloseIcon size={14} stroke={2} />
|
|
251
|
+
</ActionIcon>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Checkbox,
|
|
4
|
+
ScrollArea,
|
|
5
|
+
Text,
|
|
6
|
+
TextInput,
|
|
7
|
+
Tooltip,
|
|
8
|
+
} from "@mantine/core";
|
|
2
9
|
import { useSelector } from "@tanstack/react-store";
|
|
3
10
|
import { useState } from "react";
|
|
4
11
|
import classes from "./TMDataGridColumnsPanel.module.css";
|
|
5
12
|
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
6
13
|
import { getColumnLabel } from "../core/columnUtils";
|
|
7
14
|
import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
|
|
15
|
+
import { ROW_NUMBER_COLUMN_ID } from "./TMDataGridRowNumberColumn";
|
|
8
16
|
import { SearchIcon } from "./icons";
|
|
9
17
|
|
|
10
18
|
/**
|
|
@@ -13,7 +21,7 @@ import { SearchIcon } from "./icons";
|
|
|
13
21
|
* `TMDataGrid.ColumnsButton`.
|
|
14
22
|
*/
|
|
15
23
|
export function TMDataGridColumnsPanel() {
|
|
16
|
-
const { table, labels, controlSize } = useTMDataGridContext();
|
|
24
|
+
const { table, labels, controlSize, resetSettings } = useTMDataGridContext();
|
|
17
25
|
const [search, setSearch] = useState("");
|
|
18
26
|
|
|
19
27
|
const columnVisibility = useSelector(
|
|
@@ -23,10 +31,14 @@ export function TMDataGridColumnsPanel() {
|
|
|
23
31
|
|
|
24
32
|
// The tree column is left out rather than listed and disabled: its visibility
|
|
25
33
|
// is not a setting at all, it follows the grouping state, so an unchecked box
|
|
26
|
-
// that cannot be ticked would only invite the question.
|
|
34
|
+
// that cannot be ticked would only invite the question. The row-number
|
|
35
|
+
// gutter follows `enableRowNumbers` the same way.
|
|
27
36
|
const columns = table
|
|
28
37
|
.getAllLeafColumns()
|
|
29
|
-
.filter(
|
|
38
|
+
.filter(
|
|
39
|
+
(column) =>
|
|
40
|
+
column.id !== GROUP_COLUMN_ID && column.id !== ROW_NUMBER_COLUMN_ID,
|
|
41
|
+
);
|
|
30
42
|
const needle = search.trim().toLowerCase();
|
|
31
43
|
const visibleInPanel = needle
|
|
32
44
|
? columns.filter((column) =>
|
|
@@ -40,7 +52,7 @@ export function TMDataGridColumnsPanel() {
|
|
|
40
52
|
).length;
|
|
41
53
|
|
|
42
54
|
return (
|
|
43
|
-
<div className={classes.columnsPanel}>
|
|
55
|
+
<div data-dg-part="columns-panel" className={classes.columnsPanel}>
|
|
44
56
|
<div className={classes.columnsPanelSearch}>
|
|
45
57
|
<TextInput
|
|
46
58
|
value={search}
|
|
@@ -48,6 +60,7 @@ export function TMDataGridColumnsPanel() {
|
|
|
48
60
|
placeholder={labels.columnsSearchPlaceholder}
|
|
49
61
|
leftSection={<SearchIcon size={16} stroke={1.6} />}
|
|
50
62
|
size={controlSize}
|
|
63
|
+
data-dg-part="columns-search"
|
|
51
64
|
data-autofocus
|
|
52
65
|
/>
|
|
53
66
|
</div>
|
|
@@ -59,6 +72,8 @@ export function TMDataGridColumnsPanel() {
|
|
|
59
72
|
key={column.id}
|
|
60
73
|
size={controlSize}
|
|
61
74
|
label={getColumnLabel(column)}
|
|
75
|
+
data-dg-part="columns-toggle"
|
|
76
|
+
data-column-id={column.id}
|
|
62
77
|
disabled={!column.getCanHide()}
|
|
63
78
|
checked={columnVisibility[column.id] !== false}
|
|
64
79
|
onChange={(event) =>
|
|
@@ -78,19 +93,27 @@ export function TMDataGridColumnsPanel() {
|
|
|
78
93
|
<Checkbox
|
|
79
94
|
size={controlSize}
|
|
80
95
|
label={labels.columnsShowHideAll}
|
|
96
|
+
data-dg-part="columns-toggle-all"
|
|
81
97
|
checked={shownCount === hideableColumns.length}
|
|
82
98
|
indeterminate={shownCount > 0 && shownCount < hideableColumns.length}
|
|
83
99
|
onChange={(event) =>
|
|
84
100
|
table.toggleAllColumnsVisible(event.currentTarget.checked)
|
|
85
101
|
}
|
|
86
102
|
/>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
>
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
{/* The whole layout, not only visibility: one reset with an honest
|
|
104
|
+
scope, stated in the tooltip. `table.resetColumnVisibility()` would
|
|
105
|
+
also be wrong under persistence — it resets to `initialState`,
|
|
106
|
+
which the mount built *from* the persisted payload. */}
|
|
107
|
+
<Tooltip label={labels.columnsResetHint} withArrow>
|
|
108
|
+
<Button
|
|
109
|
+
variant="subtle"
|
|
110
|
+
size="compact-sm"
|
|
111
|
+
data-dg-part="columns-reset"
|
|
112
|
+
onClick={() => resetSettings()}
|
|
113
|
+
>
|
|
114
|
+
{labels.columnsReset}
|
|
115
|
+
</Button>
|
|
116
|
+
</Tooltip>
|
|
94
117
|
</div>
|
|
95
118
|
</div>
|
|
96
119
|
);
|
|
@@ -42,6 +42,8 @@ function DetailsCell<TData extends RowData>({
|
|
|
42
42
|
tabIndex={tabIndex}
|
|
43
43
|
aria-expanded={expanded}
|
|
44
44
|
aria-label={expanded ? labels.hideDetails : labels.showDetails}
|
|
45
|
+
data-dg-part="details-toggle"
|
|
46
|
+
data-row-id={row.id}
|
|
45
47
|
// The row underneath may select or highlight on click; opening a panel is
|
|
46
48
|
// its own gesture and must not also trigger those.
|
|
47
49
|
onClick={(event) => {
|
|
@@ -90,6 +92,7 @@ function DetailsHeader<TData extends RowData>({
|
|
|
90
92
|
aria-label={
|
|
91
93
|
allExpanded ? labels.collapseAllDetails : labels.expandAllDetails
|
|
92
94
|
}
|
|
95
|
+
data-dg-part="details-toggle-all"
|
|
93
96
|
// Partly expanded opens the rest rather than closing what is already
|
|
94
97
|
// open — the same reading as an indeterminate select-all box.
|
|
95
98
|
onClick={() =>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Button, Group } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import { useTMDataGridContext } from "../TMDataGridContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Batch mode's toolbar chrome: Save with the dirty-row count, and Discard.
|
|
7
|
+
* Both read the edit store, so they grey out while nothing is dirty and the
|
|
8
|
+
* Save spins while a submit is in flight. Works under any `editMode` — a
|
|
9
|
+
* cellConfirm grid accumulating drafts can offer the same pair — and renders
|
|
10
|
+
* nothing while editing is off.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <TMDataGrid.Toolbar>
|
|
14
|
+
* <TMDataGrid.SummaryCount />
|
|
15
|
+
* <TMDataGrid.Spacer />
|
|
16
|
+
* <TMDataGrid.EditActions />
|
|
17
|
+
* </TMDataGrid.Toolbar>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function TMDataGridEditActions() {
|
|
21
|
+
const { edit, features, labels } = useTMDataGridContext();
|
|
22
|
+
// Everything pending counts: dirty edits, entry rows (an add is pending
|
|
23
|
+
// whether or not it was touched), and deletion marks.
|
|
24
|
+
const dirtyCount = useSelector(
|
|
25
|
+
edit.store,
|
|
26
|
+
(state) =>
|
|
27
|
+
state.openRowIds.filter(
|
|
28
|
+
(rowId) =>
|
|
29
|
+
state.newRows.some((newRow) => newRow.tempId === rowId) ||
|
|
30
|
+
(state.rows[rowId]?.dirtyFields.length ?? 0) > 0,
|
|
31
|
+
).length + state.deletedRowIds.length,
|
|
32
|
+
);
|
|
33
|
+
const isSubmitting = useSelector(edit.store, (state) =>
|
|
34
|
+
state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!features.editing) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Group gap="xs" wrap="nowrap">
|
|
41
|
+
<Button
|
|
42
|
+
size="compact-sm"
|
|
43
|
+
disabled={dirtyCount === 0}
|
|
44
|
+
loading={isSubmitting}
|
|
45
|
+
data-dg-part="save-all"
|
|
46
|
+
onClick={() => void edit.submitAll()}
|
|
47
|
+
>
|
|
48
|
+
{labels.saveAllEdits(dirtyCount)}
|
|
49
|
+
</Button>
|
|
50
|
+
<Button
|
|
51
|
+
variant="subtle"
|
|
52
|
+
color="gray"
|
|
53
|
+
size="compact-sm"
|
|
54
|
+
disabled={dirtyCount === 0}
|
|
55
|
+
data-dg-part="discard-all"
|
|
56
|
+
onClick={() => edit.cancelAll()}
|
|
57
|
+
>
|
|
58
|
+
{labels.discardAllEdits}
|
|
59
|
+
</Button>
|
|
60
|
+
</Group>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ActionIcon, Group, Loader, Tooltip } from "@mantine/core";
|
|
2
|
+
import { useSelector } from "@tanstack/react-store";
|
|
3
|
+
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
|
|
4
|
+
import {
|
|
5
|
+
useCellControlTabIndex,
|
|
6
|
+
useTMDataGridContext,
|
|
7
|
+
} from "../TMDataGridContext";
|
|
8
|
+
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
9
|
+
import { firstErrorText } from "./editors/editorShared";
|
|
10
|
+
import {
|
|
11
|
+
CheckIcon,
|
|
12
|
+
CloseIcon,
|
|
13
|
+
PencilIcon,
|
|
14
|
+
RestoreIcon,
|
|
15
|
+
TrashIcon,
|
|
16
|
+
} from "./icons";
|
|
17
|
+
|
|
18
|
+
export const EDIT_COLUMN_ID = "__edit__";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One row's slot in the edit lane. Three states, driven off the edit store:
|
|
22
|
+
* a pencil while idle, Save/Cancel while the row's form is open, a loader
|
|
23
|
+
* while its commit is in flight. A row error turns the Save red and puts the
|
|
24
|
+
* message in its tooltip — the pathless `.refine()` has nowhere else to land.
|
|
25
|
+
*/
|
|
26
|
+
function EditLaneCell<TData extends RowData>({
|
|
27
|
+
row,
|
|
28
|
+
}: {
|
|
29
|
+
row: Row<TMDataGridFeatures, TData>;
|
|
30
|
+
}) {
|
|
31
|
+
const { edit, features, labels } = useTMDataGridContext();
|
|
32
|
+
const tabIndex = useCellControlTabIndex();
|
|
33
|
+
const rowId = row.id;
|
|
34
|
+
const isOpen = useSelector(edit.store, (state) =>
|
|
35
|
+
state.openRowIds.includes(rowId),
|
|
36
|
+
);
|
|
37
|
+
const projection = useSelector(edit.store, (state) => state.rows[rowId]);
|
|
38
|
+
const isNew = useSelector(edit.store, (state) =>
|
|
39
|
+
state.newRows.some((newRow) => newRow.tempId === rowId),
|
|
40
|
+
);
|
|
41
|
+
const isMarkedDeleted = useSelector(edit.store, (state) =>
|
|
42
|
+
state.deletedRowIds.includes(rowId),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (row.getIsGrouped()) return null;
|
|
46
|
+
|
|
47
|
+
// An entry row: ✓ adds it (not under batch, where submitAll does), ✕
|
|
48
|
+
// discards the entry.
|
|
49
|
+
if (isNew) {
|
|
50
|
+
return (
|
|
51
|
+
<Group gap={0} wrap="nowrap">
|
|
52
|
+
{features.editMode !== "batch" && (
|
|
53
|
+
<ActionIcon
|
|
54
|
+
variant="subtle"
|
|
55
|
+
color="green"
|
|
56
|
+
size="sm"
|
|
57
|
+
tabIndex={tabIndex}
|
|
58
|
+
aria-label={labels.confirmNewRow}
|
|
59
|
+
data-dg-part="confirm-new-row"
|
|
60
|
+
data-row-id={rowId}
|
|
61
|
+
onClick={() => void edit.commit(rowId)}
|
|
62
|
+
>
|
|
63
|
+
<CheckIcon size={16} stroke={2} />
|
|
64
|
+
</ActionIcon>
|
|
65
|
+
)}
|
|
66
|
+
<ActionIcon
|
|
67
|
+
variant="subtle"
|
|
68
|
+
color="gray"
|
|
69
|
+
size="sm"
|
|
70
|
+
tabIndex={tabIndex}
|
|
71
|
+
aria-label={labels.discardNewRow}
|
|
72
|
+
data-dg-part="discard-new-row"
|
|
73
|
+
data-row-id={rowId}
|
|
74
|
+
onClick={() => edit.cancel(rowId)}
|
|
75
|
+
>
|
|
76
|
+
<CloseIcon size={16} stroke={1.6} />
|
|
77
|
+
</ActionIcon>
|
|
78
|
+
</Group>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A batch deletion mark is a draft like any other — restorable in place.
|
|
83
|
+
if (isMarkedDeleted) {
|
|
84
|
+
return (
|
|
85
|
+
<ActionIcon
|
|
86
|
+
variant="subtle"
|
|
87
|
+
color="gray"
|
|
88
|
+
size="sm"
|
|
89
|
+
tabIndex={tabIndex}
|
|
90
|
+
aria-label={labels.restoreRow}
|
|
91
|
+
data-dg-part="restore-row"
|
|
92
|
+
data-row-id={rowId}
|
|
93
|
+
onClick={(event) => {
|
|
94
|
+
event.stopPropagation();
|
|
95
|
+
edit.deleteRow(rowId);
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<RestoreIcon size={16} stroke={1.6} />
|
|
99
|
+
</ActionIcon>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The pencil means "this row edits"; a row that does not gets nothing.
|
|
104
|
+
if (!edit.canEditRow(row as never)) return null;
|
|
105
|
+
|
|
106
|
+
if (!isOpen) {
|
|
107
|
+
return (
|
|
108
|
+
<Group gap={0} wrap="nowrap">
|
|
109
|
+
{features.editMode === "row" && (
|
|
110
|
+
<ActionIcon
|
|
111
|
+
variant="subtle"
|
|
112
|
+
color="gray"
|
|
113
|
+
size="sm"
|
|
114
|
+
tabIndex={tabIndex}
|
|
115
|
+
aria-label={labels.editRow}
|
|
116
|
+
data-dg-part="edit-row"
|
|
117
|
+
data-row-id={rowId}
|
|
118
|
+
onClick={(event) => {
|
|
119
|
+
event.stopPropagation();
|
|
120
|
+
edit.begin({ rowId, columnId: null });
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<PencilIcon size={16} stroke={1.6} />
|
|
124
|
+
</ActionIcon>
|
|
125
|
+
)}
|
|
126
|
+
{edit.canDeleteRows() && (
|
|
127
|
+
<ActionIcon
|
|
128
|
+
variant="subtle"
|
|
129
|
+
color="gray"
|
|
130
|
+
size="sm"
|
|
131
|
+
tabIndex={tabIndex}
|
|
132
|
+
aria-label={labels.deleteRow}
|
|
133
|
+
data-dg-part="delete-row"
|
|
134
|
+
data-row-id={rowId}
|
|
135
|
+
onClick={(event) => {
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
edit.deleteRow(rowId);
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<TrashIcon size={16} stroke={1.6} />
|
|
141
|
+
</ActionIcon>
|
|
142
|
+
)}
|
|
143
|
+
</Group>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (projection?.isSubmitting) {
|
|
148
|
+
return <Loader size="xs" aria-label={labels.loading} />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hasErrors =
|
|
152
|
+
projection !== undefined &&
|
|
153
|
+
(projection.hasRowError || projection.errorFields.length > 0);
|
|
154
|
+
const rowError = hasErrors
|
|
155
|
+
? (firstErrorText(edit.getForm(rowId)?.state.errors ?? []) ??
|
|
156
|
+
labels.editRowErrors)
|
|
157
|
+
: null;
|
|
158
|
+
|
|
159
|
+
const save = (
|
|
160
|
+
<ActionIcon
|
|
161
|
+
variant="subtle"
|
|
162
|
+
color={hasErrors ? "red" : "green"}
|
|
163
|
+
size="sm"
|
|
164
|
+
tabIndex={tabIndex}
|
|
165
|
+
aria-label={labels.saveRow}
|
|
166
|
+
data-dg-part="save-row"
|
|
167
|
+
data-row-id={rowId}
|
|
168
|
+
onClick={(event) => {
|
|
169
|
+
event.stopPropagation();
|
|
170
|
+
void edit.commit(rowId);
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<CheckIcon size={16} stroke={2} />
|
|
174
|
+
</ActionIcon>
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<Group gap={0} wrap="nowrap">
|
|
179
|
+
{rowError === null ? (
|
|
180
|
+
save
|
|
181
|
+
) : (
|
|
182
|
+
<Tooltip label={rowError} withArrow>
|
|
183
|
+
{save}
|
|
184
|
+
</Tooltip>
|
|
185
|
+
)}
|
|
186
|
+
<ActionIcon
|
|
187
|
+
variant="subtle"
|
|
188
|
+
color="gray"
|
|
189
|
+
size="sm"
|
|
190
|
+
tabIndex={tabIndex}
|
|
191
|
+
aria-label={labels.cancelRowEdit}
|
|
192
|
+
data-dg-part="cancel-row"
|
|
193
|
+
data-row-id={rowId}
|
|
194
|
+
onClick={(event) => {
|
|
195
|
+
event.stopPropagation();
|
|
196
|
+
edit.cancel(rowId);
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<CloseIcon size={16} stroke={1.6} />
|
|
200
|
+
</ActionIcon>
|
|
201
|
+
</Group>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The generated edit lane, appended and pinned right under
|
|
207
|
+
* `editMode: "row"` — the row's Save at the end of the row, mirroring the
|
|
208
|
+
* checkbox lane's build on the left.
|
|
209
|
+
*/
|
|
210
|
+
export function createEditColumn<TData extends RowData>(
|
|
211
|
+
label = "Edit",
|
|
212
|
+
): ColumnDef<TMDataGridFeatures, TData, unknown> {
|
|
213
|
+
return {
|
|
214
|
+
id: EDIT_COLUMN_ID,
|
|
215
|
+
meta: {
|
|
216
|
+
label,
|
|
217
|
+
align: "center",
|
|
218
|
+
enableOrdering: false,
|
|
219
|
+
},
|
|
220
|
+
// Wide enough for the Save/Cancel pair it holds while editing.
|
|
221
|
+
size: 64,
|
|
222
|
+
minSize: 64,
|
|
223
|
+
maxSize: 64,
|
|
224
|
+
enableResizing: false,
|
|
225
|
+
enableSorting: false,
|
|
226
|
+
enableColumnFilter: false,
|
|
227
|
+
enableGlobalFilter: false,
|
|
228
|
+
// Structurally pinned to the right; not movable.
|
|
229
|
+
enablePinning: false,
|
|
230
|
+
header: () => null,
|
|
231
|
+
cell: ({ row }) => <EditLaneCell row={row} />,
|
|
232
|
+
// Group rows: same reasoning as the checkbox lane — without this the
|
|
233
|
+
// cell renders blank on aggregated rows, but here blank is also correct,
|
|
234
|
+
// so the aggregated cell renders the same (null for groups).
|
|
235
|
+
aggregatedCell: ({ row }) => <EditLaneCell row={row} />,
|
|
236
|
+
};
|
|
237
|
+
}
|