@jielga/tmdatagrid 2.0.0-beta.4 → 2.0.0-beta.7

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.
Files changed (37) hide show
  1. package/dist/index.d.ts +339 -152
  2. package/dist/index.js +2602 -2450
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -1
  6. package/skills/appearance/SKILL.md +1 -1
  7. package/skills/cell-selection/SKILL.md +1 -1
  8. package/skills/columns/SKILL.md +44 -2
  9. package/skills/data/SKILL.md +14 -10
  10. package/skills/editing/SKILL.md +140 -68
  11. package/skills/editing/references/common-mistakes.md +98 -8
  12. package/skills/editing/references/editing-api.md +40 -30
  13. package/skills/editing/references/editors-and-validation.md +65 -14
  14. package/skills/filtering/SKILL.md +1 -1
  15. package/skills/getting-started/SKILL.md +3 -3
  16. package/skills/grouping/SKILL.md +5 -3
  17. package/skills/options/SKILL.md +2 -2
  18. package/skills/rows/SKILL.md +7 -3
  19. package/skills/server-side/SKILL.md +1 -1
  20. package/skills/testing/SKILL.md +1 -1
  21. package/src/tmdatagrid/components/TMDataGrid.tsx +2 -2
  22. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +33 -34
  23. package/src/tmdatagrid/components/TMDataGridDraftActions.tsx +296 -0
  24. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +40 -33
  25. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +7 -7
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +27 -5
  27. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +4 -0
  28. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +8 -10
  29. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +34 -4
  30. package/src/tmdatagrid/components/editors/editorShared.ts +1 -29
  31. package/src/tmdatagrid/core/capabilities.ts +14 -6
  32. package/src/tmdatagrid/core/editEngine.ts +472 -96
  33. package/src/tmdatagrid/core/labels.ts +3 -3
  34. package/src/tmdatagrid/core/summary.ts +10 -2
  35. package/src/tmdatagrid/index.ts +9 -7
  36. package/src/tmdatagrid/useTMDataGrid.tsx +107 -47
  37. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -233
@@ -43,8 +43,8 @@ const BUILT_IN_EDITORS: Record<
43
43
  export type TMDataGridCellEditorClose =
44
44
  | { committed: true; via: "enter" | "tab" | "shift-tab" | "pick" }
45
45
  | { committed: false; via: "escape" }
46
- /** Draft: the editor closed, the draft stays - Enter parks, Tab moves on. */
47
- | { committed: false; via: "defer" | "defer-tab" | "defer-shift-tab" };
46
+ /** `cellConfirm`: the editor closed, the draft stays, waiting for its ✓. */
47
+ | { committed: false; via: "defer-tab" | "defer-shift-tab" };
48
48
 
49
49
  /**
50
50
  * The host an editing cell mounts - it owns the field, the keys and the blur
@@ -68,10 +68,10 @@ export function TMDataGridCellEditor({
68
68
  /** After the editor closed itself - the table moves the focus. */
69
69
  onClose: (args: TMDataGridCellEditorClose) => void;
70
70
  /**
71
- * Hosted by the entry block rather than a body cell. Under draft the keys
72
- * change meaning there: Enter confirms the whole entry (the engine parks
73
- * it), and Tab stays the browser's, walking the entry row's own inputs the
74
- * way row mode's do.
71
+ * Hosted by the entry block rather than a body cell. An entry row is
72
+ * row-shaped in every mode: Enter enters the whole row, Tab stays the
73
+ * browser's - walking the row's own inputs the way row mode's do - and
74
+ * focus leaving decides nothing, because the ✓ is the decision.
75
75
  */
76
76
  inEntryBlock?: boolean;
77
77
  }) {
@@ -79,8 +79,9 @@ export function TMDataGridCellEditor({
79
79
  // Row mode mounts one host per editable cell of the row; every key that
80
80
  // commits or cancels acts on the whole row's form either way, but Tab must
81
81
  // stay the browser's (it walks the row's inputs) and blur must do nothing
82
- // (focus moves between this row's own editors).
83
- const isRowMode = features.editMode === "row";
82
+ // (focus moves between this row's own editors). The entry block is built
83
+ // the same way, in every mode, so it follows the same two rules.
84
+ const isRowShaped = features.editMode === "row" || inEntryBlock;
84
85
  const column = cell.column;
85
86
  const form = edit.getForm(row.id);
86
87
  const fieldName = getEditFieldName(column);
@@ -173,11 +174,11 @@ export function TMDataGridCellEditor({
173
174
  };
174
175
 
175
176
  /**
176
- * Draft: the key closes the editor and leaves the row open - undecided
177
- * form state, which `saveDrafts` does not send. Tab moving along the row
178
- * is not a decision about it; Enter is, and commits instead.
177
+ * `cellConfirm`: the key closes the editor and leaves the draft where it
178
+ * is, waiting for the ✓. Moving along the row is not a decision about it;
179
+ * Enter is, and commits instead.
179
180
  */
180
- const deferAndClose = (via: "defer" | "defer-tab" | "defer-shift-tab") => {
181
+ const deferAndClose = (via: "defer-tab" | "defer-shift-tab") => {
181
182
  if (closingRef.current) return;
182
183
  closingRef.current = true;
183
184
  edit.deactivate();
@@ -185,26 +186,23 @@ export function TMDataGridCellEditor({
185
186
  };
186
187
 
187
188
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
188
- const isDraft = features.editMode === "draft";
189
189
  if (event.key === "Enter") {
190
190
  event.preventDefault();
191
191
  event.stopPropagation();
192
192
  // Enter is OK, in every mode: the row's form submits and has to pass
193
- // validation. What a commit *does* is the mode's business - the
194
- // immediate modes call the consumer, draft mode parks the row in the
195
- // draft store for `saveDrafts` - so this key does not branch.
193
+ // validation. Where a commit *goes* is `editing.draft`'s business, not
194
+ // this key's - out to the consumer, or into the draft store.
196
195
  void commitAndClose("enter");
197
196
  } else if (event.key === "Escape") {
198
197
  event.preventDefault();
199
198
  event.stopPropagation();
200
199
  cancelAndClose();
201
- } else if (event.key === "Tab" && !isRowMode && !(isDraft && inEntryBlock)) {
202
- // In the draft entry block Tab stays the browser's: every editable
203
- // cell's editor is mounted, so it walks the row's inputs as row mode's
204
- // does.
200
+ } else if (event.key === "Tab" && !isRowShaped) {
205
201
  event.preventDefault();
206
202
  event.stopPropagation();
207
- if (isDraft) {
203
+ // `cellConfirm` is the one mode where leaving a cell decides nothing:
204
+ // the caret moves on and the draft waits for its ✓.
205
+ if (features.editMode === "cellConfirm") {
208
206
  deferAndClose(event.shiftKey ? "defer-shift-tab" : "defer-tab");
209
207
  } else {
210
208
  void commitAndClose(event.shiftKey ? "shift-tab" : "tab");
@@ -214,27 +212,28 @@ export function TMDataGridCellEditor({
214
212
 
215
213
  /**
216
214
  * Focus left the editor entirely - a click somewhere else. Under `"cell"`
217
- * that commits (Sheets); a commit blocked by validation keeps the form and
218
- * its invalid marker, with the editor closed. The confirming modes keep
219
- * the draft open-but-inactive, waiting for their explicit ✓.
215
+ * that commits (Sheets): a row the user has walked away from is a row they
216
+ * are done typing into, and where the commit lands is `editing.draft`'s
217
+ * business. A commit blocked by validation keeps the form and its invalid
218
+ * marker, with the editor closed.
220
219
  */
221
220
  const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
222
221
  if (closingRef.current) return;
223
- // Row mode: the row's other editors are outside this host, and focus
224
- // moving between them is not leaving the edit. Blur means nothing here;
225
- // the lane's Save/Cancel and the keys end the edit.
226
- if (isRowMode) return;
222
+ // Row mode and the entry block: the row's other editors are outside this
223
+ // host, and focus moving between them is not leaving the edit. Blur means
224
+ // nothing there; the lane's buttons and the keys end the edit.
225
+ if (isRowShaped) return;
227
226
  const next = event.relatedTarget;
228
227
  if (next instanceof Node && event.currentTarget.contains(next)) return;
229
228
  closingRef.current = true;
230
- if (features.editMode === "cell") {
231
- void edit.commit(row.id).then((ok) => {
232
- if (!ok) edit.deactivate();
233
- });
234
- } else {
235
- // cellConfirm: the draft stays, dirty-marked, waiting for its ✓.
229
+ if (features.editMode === "cellConfirm") {
230
+ // The draft stays, dirty-marked, waiting for its ✓.
236
231
  edit.deactivate();
232
+ return;
237
233
  }
234
+ void edit.commit(row.id).then((ok) => {
235
+ if (!ok) edit.deactivate();
236
+ });
238
237
  };
239
238
 
240
239
  if (form === undefined || fieldName === null) return null;
@@ -0,0 +1,296 @@
1
+ import { Button, Group, Text } from "@mantine/core";
2
+ import { useSelector } from "@tanstack/react-store";
3
+ import { shallow } from "@tanstack/store";
4
+ import type { ReactNode } from "react";
5
+ import { useTMDataGridContext } from "../TMDataGridContext";
6
+ import { getOpenRowIds } from "../core/editEngine";
7
+ import { getDisplayedRows } from "../core/rowSelection";
8
+ import type {
9
+ TMDataGridScrollAlign,
10
+ TMDataGridScrollToRowArgs,
11
+ } from "../useTMDataGrid";
12
+
13
+ /**
14
+ * How many rows the draft store holds - parked edits, parked entry rows and
15
+ * deletion marks. This is what Save sends, so it is what Save counts: a row
16
+ * the user is still typing into is not in here.
17
+ */
18
+ function useDraftCount(): number {
19
+ const { edit } = useTMDataGridContext();
20
+ return useSelector(
21
+ edit.store,
22
+ (state) =>
23
+ state.committedRowIds.length +
24
+ state.newRows.filter((newRow) => newRow.committed).length +
25
+ state.deletedRowIds.length,
26
+ );
27
+ }
28
+
29
+ /**
30
+ * How many rows are still open - edited or entered but not parked, so not
31
+ * part of the save. Surfaced beside Save so an undecided row is visible
32
+ * rather than silently left behind.
33
+ */
34
+ function useOpenCount(): number {
35
+ const { edit } = useTMDataGridContext();
36
+ return useSelector(edit.store, (state) => getOpenRowIds(state).length);
37
+ }
38
+
39
+ /**
40
+ * The ids behind {@link useOpenCount}. Shallow-compared: the array would
41
+ * otherwise be a new one on every edit-store publish, and it is handed to
42
+ * `renderActions`, where it may well end up in a dependency array.
43
+ */
44
+ function useOpenRowIds(): ReadonlyArray<string> {
45
+ const { edit } = useTMDataGridContext();
46
+ return useSelector(edit.store, getOpenRowIds, { compare: shallow });
47
+ }
48
+
49
+ /*
50
+ * Module scope, like the pagination controls and for the same reason: a
51
+ * component defined inside the render body is a new type every render, so
52
+ * React would remount the buttons underneath an interaction.
53
+ */
54
+
55
+ function EditSaveButton() {
56
+ const { edit, labels } = useTMDataGridContext();
57
+ const draftCount = useDraftCount();
58
+ const isSubmitting = useSelector(edit.store, (state) =>
59
+ state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
60
+ );
61
+
62
+ return (
63
+ <Button
64
+ size="compact-sm"
65
+ disabled={draftCount === 0}
66
+ loading={isSubmitting}
67
+ data-dg-part="save-all"
68
+ data-draft-count={draftCount}
69
+ onClick={() => void edit.saveDrafts()}
70
+ >
71
+ {labels.saveAllEdits(draftCount)}
72
+ </Button>
73
+ );
74
+ }
75
+
76
+ /** The count of rows left open, or nothing while every row is decided. */
77
+ function EditOpenRowsNote() {
78
+ const { labels } = useTMDataGridContext();
79
+ const openCount = useOpenCount();
80
+ if (openCount === 0) return null;
81
+ return (
82
+ <Text
83
+ size="xs"
84
+ c="dimmed"
85
+ data-dg-part="open-rows-note"
86
+ data-open-count={openCount}
87
+ >
88
+ {labels.editRowsStillOpen(openCount)}
89
+ </Text>
90
+ );
91
+ }
92
+
93
+ function EditDiscardButton() {
94
+ const { edit, labels } = useTMDataGridContext();
95
+ // Discard drops everything the grid is holding, open rows included, so it
96
+ // stays live while anything is uncommitted.
97
+ const pendingCount = useDraftCount() + useOpenCount();
98
+
99
+ return (
100
+ <Button
101
+ variant="subtle"
102
+ color="gray"
103
+ size="compact-sm"
104
+ disabled={pendingCount === 0}
105
+ data-dg-part="discard-all"
106
+ onClick={() => edit.cancelAll()}
107
+ >
108
+ {labels.discardAllEdits}
109
+ </Button>
110
+ );
111
+ }
112
+
113
+ const EDIT_ACTIONS_CONTROLS: TMDataGridDraftActionsControls = {
114
+ Save: EditSaveButton,
115
+ Discard: EditDiscardButton,
116
+ OpenRowsNote: EditOpenRowsNote,
117
+ };
118
+
119
+ /** What the edit chrome is showing. */
120
+ export type TMDataGridDraftActionsState = {
121
+ /**
122
+ * Rows in the draft store, which is what Save sends: committed edits,
123
+ * committed entry rows and deletion marks.
124
+ */
125
+ draftCount: number;
126
+ /**
127
+ * Rows still open - edited or entered but not committed, so not part of
128
+ * the save. They stay open across a save.
129
+ */
130
+ openCount: number;
131
+ /**
132
+ * The ids behind {@link openCount}, in the order the grid opened them - a
133
+ * sort, a filter or a page never moves this list. An entered row appears as
134
+ * its `tempId`.
135
+ *
136
+ * Narrower than `edit.state.openRowIds`, which is every row holding a form,
137
+ * the parked ones included. And note the ordering:
138
+ * {@link TMDataGridDraftActionsActions.scrollToFirstOpenRow} takes "first"
139
+ * in display order, so it need not be `openRowIds[0]`.
140
+ */
141
+ openRowIds: ReadonlyArray<string>;
142
+ /**
143
+ * @deprecated Was "everything uncommitted", which Save no longer sends.
144
+ * Reads as `draftCount + openCount`; use whichever you meant.
145
+ */
146
+ pendingCount: number;
147
+ /** Whether a submit is in flight. */
148
+ isSubmitting: boolean;
149
+ };
150
+
151
+ /** What the edit chrome can do. */
152
+ export type TMDataGridDraftActionsActions = {
153
+ /** Saves the draft store. Open rows are left alone. */
154
+ save: () => Promise<boolean>;
155
+ /** Submits every open row, committing the ones that validate. */
156
+ commitAll: () => Promise<boolean>;
157
+ /** Drops everything - open form state and the draft store alike. */
158
+ discard: () => void;
159
+ /**
160
+ * `grid.scrollToRow`, so a control in here can take the user to a row
161
+ * without the grid being threaded down to it.
162
+ */
163
+ scrollToRow: (args: TMDataGridScrollToRowArgs) => boolean;
164
+ /**
165
+ * Scrolls to the first row still open, taking "first" in display order: the
166
+ * topmost open row under the current sort, filter and page. That need not
167
+ * be {@link TMDataGridDraftActionsState.openRowIds}`[0]`, which is the
168
+ * order the grid opened them in.
169
+ *
170
+ * Answers whether an open row could be reached. `false` when nothing is
171
+ * open, and when every open row is filtered out, on another page or
172
+ * collapsed inside a group. An open entry row answers `true` without
173
+ * scrolling - it is sticky under the header, so it is on screen already -
174
+ * and so does an open row pinned to an edge. The scroll goes through
175
+ * `scrollToRow`, so before `TMDataGrid.Table` has mounted there is nothing
176
+ * to scroll and the answer is `false`.
177
+ */
178
+ scrollToFirstOpenRow: (align?: TMDataGridScrollAlign) => boolean;
179
+ };
180
+
181
+ /** The pre-bound pieces of the built-in edit chrome. */
182
+ export type TMDataGridDraftActionsControls = {
183
+ /** Save, with the draft count, disabled while the draft store is empty. */
184
+ Save: () => ReactNode;
185
+ /** Discard, disabled while nothing is pending. */
186
+ Discard: () => ReactNode;
187
+ /** The "N rows still being edited" note, or nothing while there are none. */
188
+ OpenRowsNote: () => ReactNode;
189
+ };
190
+
191
+ /** What {@link TMDataGridDraftActionsProps.renderActions} is handed. */
192
+ export type TMDataGridDraftActionsSlotArgs = {
193
+ state: TMDataGridDraftActionsState;
194
+ actions: TMDataGridDraftActionsActions;
195
+ Controls: TMDataGridDraftActionsControls;
196
+ };
197
+
198
+ export type TMDataGridDraftActionsProps = {
199
+ /**
200
+ * Replaces the built-in Save/Discard pair, and is handed the pieces of it.
201
+ *
202
+ * ```tsx
203
+ * <TMDataGrid.DraftActions
204
+ * renderActions={({ state, Controls }) => (
205
+ * <Group>
206
+ * {state.draftCount > 0 && <Badge>{state.draftCount}</Badge>}
207
+ * <Controls.OpenRowsNote />
208
+ * <Controls.Save />
209
+ * <Controls.Discard />
210
+ * </Group>
211
+ * )}
212
+ * />
213
+ * ```
214
+ */
215
+ renderActions?: (args: TMDataGridDraftActionsSlotArgs) => ReactNode;
216
+ };
217
+
218
+ /**
219
+ * The draft store's toolbar chrome: Save with the store's count, Discard, and
220
+ * a note counting the rows still open. Save sends the store and leaves open
221
+ * rows alone, so it greys out while nothing is parked however much is being
222
+ * typed - the note is what makes those rows visible.
223
+ *
224
+ * Works under any `editing.mode`. The toolbar is declarative: the grid does
225
+ * not decide for you, so include this only when the grid runs a draft store -
226
+ * without `editing.draft` there is nothing to save and Save stays disabled.
227
+ *
228
+ * ```tsx
229
+ * <TMDataGrid.Toolbar>
230
+ * <TMDataGrid.SummaryCount />
231
+ * <TMDataGrid.Spacer />
232
+ * <TMDataGrid.DraftActions />
233
+ * </TMDataGrid.Toolbar>
234
+ * ```
235
+ */
236
+ export function TMDataGridDraftActions({
237
+ renderActions,
238
+ }: TMDataGridDraftActionsProps = {}) {
239
+ const { edit, features, table, scrollToRow } = useTMDataGridContext();
240
+ const draftCount = useDraftCount();
241
+ const openRowIds = useOpenRowIds();
242
+ const isSubmitting = useSelector(edit.store, (state) =>
243
+ state.openRowIds.some((rowId) => state.rows[rowId]?.isSubmitting === true),
244
+ );
245
+
246
+ if (!features.editing) return null;
247
+
248
+ if (renderActions) {
249
+ return renderActions({
250
+ state: {
251
+ draftCount,
252
+ openCount: openRowIds.length,
253
+ openRowIds,
254
+ pendingCount: draftCount + openRowIds.length,
255
+ isSubmitting,
256
+ },
257
+ actions: {
258
+ save: () => edit.saveDrafts(),
259
+ commitAll: () => edit.commitAll(),
260
+ discard: () => edit.cancelAll(),
261
+ scrollToRow,
262
+ scrollToFirstOpenRow: (align) => {
263
+ // Read through the store rather than closing over this render's
264
+ // arrays: display order is only decided here, at the click.
265
+ const state = edit.state;
266
+ const openIds = getOpenRowIds(state);
267
+ if (openIds.length === 0) return false;
268
+ const open = new Set(openIds);
269
+ const first = getDisplayedRows(table, features).find((row) =>
270
+ open.has(row.id),
271
+ );
272
+ if (first !== undefined) {
273
+ return scrollToRow({ rowId: first.id, align });
274
+ }
275
+ // Nothing left in the scrolling order, which does not mean nothing
276
+ // is visible: an entry row is sticky under the header, and a pinned
277
+ // row is parked at an edge - `scrollToRow` answers for that one.
278
+ return openIds.some(
279
+ (rowId) =>
280
+ state.newRows.some((newRow) => newRow.tempId === rowId) ||
281
+ scrollToRow({ rowId, align }),
282
+ );
283
+ },
284
+ },
285
+ Controls: EDIT_ACTIONS_CONTROLS,
286
+ });
287
+ }
288
+
289
+ return (
290
+ <Group gap="xs" wrap="nowrap">
291
+ <EditOpenRowsNote />
292
+ <EditSaveButton />
293
+ <EditDiscardButton />
294
+ </Group>
295
+ );
296
+ }
@@ -7,7 +7,7 @@ import {
7
7
  useTMDataGridContext,
8
8
  } from "../TMDataGridContext";
9
9
  import type { TMDataGridFeatures } from "../useTMDataGrid";
10
- import { firstErrorText } from "./editors/editorShared";
10
+ import { firstErrorText } from "../core/editEngine";
11
11
  import {
12
12
  CheckIcon,
13
13
  CloseIcon,
@@ -20,10 +20,10 @@ import {
20
20
  export const EDIT_COLUMN_ID = "__edit__";
21
21
 
22
22
  /**
23
- * Draft mode's change marker in the lane - what kind of uncommitted change
24
- * the row carries. Non-interactive; the buttons beside it act. `data-state`
25
- * is the styling hook, `data-error` flips it red and puts the message in the
26
- * tooltip in place of the state's name.
23
+ * The draft store's change marker in the lane - what kind of change the row
24
+ * is holding for the save. Non-interactive; the buttons beside it act.
25
+ * `data-state` is the styling hook, `data-error` flips it red and puts the
26
+ * message in the tooltip in place of the state's name.
27
27
  */
28
28
  function RowStateIndicator({
29
29
  state,
@@ -56,13 +56,19 @@ function RowStateIndicator({
56
56
  }
57
57
 
58
58
  /**
59
- * One row's slot in the edit lane, driven off the edit store. Outside draft
60
- * mode: a pencil while idle, Save/Cancel while the row's form is open, a
61
- * loader while its commit is in flight; a row error turns the Save red with
62
- * the message in its tooltip - the pathless `.refine()` has nowhere else to
63
- * land. Under draft mode the lane is the change marker and the per-row
64
- * revert instead: a state icon (new/edited/deleted) beside undo, discard or
65
- * restore - never a per-row save, which the engine would only park anyway.
59
+ * One row's slot in the edit lane, driven off the edit store. Two things
60
+ * decide what it holds, and they are the two axes of `editing`:
61
+ *
62
+ * | Row is | Lane holds | From |
63
+ * | ------ | ---------- | ---- |
64
+ * | parked in the draft store | the change marker and the way back out - revert, discard or restore | `editing.draft` |
65
+ * | open | the mode's own controls: Save and Cancel | `editing.mode` |
66
+ * | neither | the pencil (row mode) and the trash | `editing.mode` |
67
+ *
68
+ * A row error turns the Save red with the message in its tooltip - a
69
+ * pathless `.refine()` has nowhere else to land, and a field error outlives
70
+ * the editor that found it. A parked row never offers a save: it has had its
71
+ * submit, and `TMDataGrid.DraftActions` is what sends it.
66
72
  */
67
73
  function EditLaneCell<TData extends RowData>({
68
74
  row,
@@ -85,18 +91,25 @@ function EditLaneCell<TData extends RowData>({
85
91
  state.newRows.find((newRow) => newRow.tempId === rowId)?.committed ===
86
92
  true,
87
93
  );
94
+ // Parked: the row passed its submit and is waiting for the save. A subset
95
+ // of the open rows, so it is asked about first - the two overlap.
96
+ const isCommitted = useSelector(edit.store, (state) =>
97
+ state.committedRowIds.includes(rowId),
98
+ );
88
99
  const isMarkedDeleted = useSelector(edit.store, (state) =>
89
100
  state.deletedRowIds.includes(rowId),
90
101
  );
91
102
 
92
103
  if (row.getIsGrouped()) return null;
93
104
 
94
- const isDraftMode = features.editMode === "draft";
95
105
  const hasErrors =
96
106
  projection !== undefined &&
97
107
  (projection.hasRowError || projection.errorFields.length > 0);
108
+ // The row's own message first - a pathless issue is about the row - then
109
+ // the first field's, which is what a failed commit left behind.
98
110
  const rowError = hasErrors
99
111
  ? (firstErrorText(edit.getForm(rowId)?.state.errors ?? []) ??
112
+ projection.errorMessages[0]?.message ??
100
113
  labels.editRowErrors)
101
114
  : null;
102
115
 
@@ -120,9 +133,9 @@ function EditLaneCell<TData extends RowData>({
120
133
  </Tooltip>
121
134
  ) : null;
122
135
 
123
- // An entry row: ✓ enters it - an add under the immediate modes, a parked
124
- // confirm under draft - and ✕ discards the entry. A confirmed entry waits
125
- // for Save all as a value row: marked new, re-openable, still discardable.
136
+ // An entry row: ✓ enters it - an add, or a park under `editing.draft` -
137
+ // and ✕ discards the entry. A parked entry waits for the save as a value
138
+ // row: marked new, re-openable, still discardable.
126
139
  if (isNew) {
127
140
  if (isConfirmedNew) {
128
141
  return (
@@ -201,7 +214,7 @@ function EditLaneCell<TData extends RowData>({
201
214
  );
202
215
  }
203
216
 
204
- // A draft-mode deletion mark is a draft like any other - restorable in place.
217
+ // A deletion mark is a draft like any other - restorable in place.
205
218
  if (isMarkedDeleted) {
206
219
  return (
207
220
  <Group gap={0} wrap="nowrap">
@@ -235,16 +248,14 @@ function EditLaneCell<TData extends RowData>({
235
248
  // The pencil means "this row edits"; a row that does not gets nothing.
236
249
  if (!edit.canEditRow(row as never)) return null;
237
250
 
238
- // Draft mode never offers a per-row save - the engine would only park it,
239
- // and Save all is the commit. The lane is the state marker and the revert.
240
- // No trash beside the revert: the two undo-shaped icons were too easy to
241
- // mistake for one another, so a dirty row reverts first, then deletes.
242
- if (isDraftMode) {
243
- if (projection?.isSubmitting) {
244
- return <Loader size="xs" aria-label={labels.loading} />;
245
- }
246
- const isDirty = (projection?.dirtyFields.length ?? 0) > 0;
247
- if (!isDirty) return trash;
251
+ if (projection?.isSubmitting) {
252
+ return <Loader size="xs" aria-label={labels.loading} />;
253
+ }
254
+
255
+ // Parked in the draft store: the mark, and the way back out. No trash
256
+ // beside the revert - the two undo-shaped icons were too easy to mistake
257
+ // for one another, so a drafted row reverts first, then deletes.
258
+ if (isCommitted) {
248
259
  return (
249
260
  <Group gap={0} wrap="nowrap">
250
261
  <RowStateIndicator
@@ -301,10 +312,6 @@ function EditLaneCell<TData extends RowData>({
301
312
  );
302
313
  }
303
314
 
304
- if (projection?.isSubmitting) {
305
- return <Loader size="xs" aria-label={labels.loading} />;
306
- }
307
-
308
315
  const save = (
309
316
  <ActionIcon
310
317
  variant="subtle"
@@ -358,11 +365,11 @@ function EditLaneCell<TData extends RowData>({
358
365
  /**
359
366
  * The generated edit lane, appended and pinned right - the row's Save at the
360
367
  * end of the row under `mode: "row"`, the state marker and revert under
361
- * `mode: "draft"`, mirroring the checkbox lane's build on the left.
368
+ * `editing.draft`, mirroring the checkbox lane's build on the left.
362
369
  */
363
370
  export function createEditColumn<TData extends RowData>(
364
371
  label = "Edit",
365
- /** Draft mode holds three controls where the others hold two. */
372
+ /** A draft lane holds three controls where the rest hold two. */
366
373
  wide = false,
367
374
  ): ColumnDef<TMDataGridFeatures, TData, unknown> {
368
375
  const width = wide ? 88 : 64;
@@ -14,7 +14,7 @@ import {
14
14
  isColumnEditableForRow,
15
15
  isControlColumn,
16
16
  } from "../core/columnUtils";
17
- import { getEditFieldName } from "../core/editEngine";
17
+ import type { TMDataGridEditApi } from "../core/editEngine";
18
18
  import { focusEditorContent } from "../core/editorFocus";
19
19
  import {
20
20
  tmDataGridFeatures,
@@ -34,9 +34,9 @@ type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
34
34
  function isEntryCellEditable(
35
35
  row: Row<TMDataGridFeatures, TMDataGridRowData>,
36
36
  column: ErasedColumn,
37
+ edit: TMDataGridEditApi,
37
38
  ): boolean {
38
- if (isControlColumn(column.id)) return false;
39
- if (getEditFieldName(column) === null) return false;
39
+ if (!edit.isColumnEditable(column)) return false;
40
40
  return isColumnEditableForRow(column, row);
41
41
  }
42
42
 
@@ -52,9 +52,9 @@ function isEntryCellEditable(
52
52
  * validators all apply unchanged. The forms live in the same engine map as
53
53
  * every other draft, keyed by their temporary ids.
54
54
  *
55
- * Enter commits the entry (`onRowAdd`) under the immediate modes and parks
56
- * it under draft, where `submitAll` reports it in `added`; Escape discards
57
- * it. The edit lane, when present, offers the same pair as buttons.
55
+ * Enter commits the entry - `onRowAdd`, or a park under `editing.draft`,
56
+ * where `saveDrafts` reports it in `created`; Escape discards it. The edit
57
+ * lane, when present, offers the same pair as buttons.
58
58
  */
59
59
  export function TMDataGridEntryRows({
60
60
  orderedColumns,
@@ -201,7 +201,7 @@ export function TMDataGridEntryRows({
201
201
  const cell = cellsById.get(column.id);
202
202
  const layout = layoutFor(column.id);
203
203
  const editable =
204
- cell !== undefined && isEntryCellEditable(entryRow, column);
204
+ cell !== undefined && isEntryCellEditable(entryRow, column, edit);
205
205
  return (
206
206
  <div
207
207
  key={column.id}