@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.
@@ -111,10 +111,15 @@ export type TMDataGridLabels = {
111
111
  cancelRowEdit: string;
112
112
  /** Save tooltip when field errors block the row, with no row message. */
113
113
  editRowErrors: string;
114
- /** `EditActions`' Save, with the dirty-row count. */
114
+ /** `EditActions`' Save, with the count of rows in the draft store. */
115
115
  saveAllEdits: (rows: number) => string;
116
116
  /** `EditActions`' Discard. */
117
117
  discardAllEdits: string;
118
+ /**
119
+ * `EditActions`' note about rows still open - edited but not committed, so
120
+ * not part of the save. Shown only while there are any.
121
+ */
122
+ editRowsStillOpen: (rows: number) => string;
118
123
  /** The entry row's ✓ - commit the add. */
119
124
  confirmNewRow: string;
120
125
  /** The entry row's ✕ - drop the entry. */
@@ -243,6 +248,8 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
243
248
  editRowErrors: "Fix the marked cells",
244
249
  saveAllEdits: (rows) => (rows === 1 ? "Save 1 row" : `Save ${rows} rows`),
245
250
  discardAllEdits: "Discard",
251
+ editRowsStillOpen: (rows) =>
252
+ rows === 1 ? "1 row still being edited" : `${rows} rows still being edited`,
246
253
  confirmNewRow: "Add row",
247
254
  discardNewRow: "Discard new row",
248
255
  deleteRow: "Delete row",
@@ -103,6 +103,8 @@ export const TMDATAGRID_LABELS_SV: TMDataGridLabels = {
103
103
  editRowErrors: "Åtgärda de markerade cellerna",
104
104
  saveAllEdits: (rows) => (rows === 1 ? "Spara 1 rad" : `Spara ${rows} rader`),
105
105
  discardAllEdits: "Förkasta",
106
+ editRowsStillOpen: (rows) =>
107
+ rows === 1 ? "1 rad redigeras fortfarande" : `${rows} rader redigeras fortfarande`,
106
108
  confirmNewRow: "Lägg till rad",
107
109
  discardNewRow: "Släng ny rad",
108
110
  deleteRow: "Ta bort rad",
@@ -82,7 +82,12 @@ export {
82
82
  type TMDataGridEditApi,
83
83
  type TMDataGridEditChange,
84
84
  type TMDataGridEditCommitArgs,
85
+ type TMDataGridSaveDraftsArgs,
86
+ type TMDataGridSaveDraftsResult,
87
+ type TMDataGridSaveOutcomes,
85
88
  type TMDataGridEditCommitDraftsArgs,
89
+ type TMDataGridAddRowsOptions,
90
+ type TMDataGridAddRowsResult,
86
91
  type TMDataGridEditField,
87
92
  type TMDataGridEditMode,
88
93
  type TMDataGridEditorArgs,
@@ -51,7 +51,8 @@ import {
51
51
  createEditEngine,
52
52
  type TMDataGridEditApi,
53
53
  type TMDataGridEditCommitArgs,
54
- type TMDataGridEditCommitDraftsArgs,
54
+ type TMDataGridSaveDraftsArgs,
55
+ type TMDataGridSaveDraftsResult,
55
56
  type TMDataGridEditEngineContext,
56
57
  type TMDataGridColumnEditOptions,
57
58
  type TMDataGridEditMode,
@@ -93,6 +94,11 @@ import {
93
94
  isSameCell,
94
95
  type TMDataGridCellPosition,
95
96
  } from "./core/cellNavigation";
97
+ import {
98
+ findFrozenStateSlices,
99
+ stabilizeControlledState,
100
+ withoutUndefinedSlices,
101
+ } from "./core/controlledState";
96
102
  import type { TMDataGridCellRange } from "./core/cellRange";
97
103
  import {
98
104
  createSelectColumn,
@@ -390,7 +396,7 @@ export type TMDataGridApi<TData extends RowData> = {
390
396
  * out the same TanStack Form the inline editors write through, so a drawer
391
397
  * or detail panel can share a row's draft. Inert until `editing` is set.
392
398
  */
393
- edit: TMDataGridEditApi;
399
+ edit: TMDataGridEditApi<TData>;
394
400
  /** Table-level feature switches, re-read from options on every render. */
395
401
  features: TMDataGridFeatureFlags;
396
402
  /** Every string the chrome renders, `labels` merged over the English defaults. */
@@ -471,6 +477,7 @@ type TMDataGridEditingCallbacks<TData extends RowData> = {
471
477
  /**
472
478
  * Seed values for `edit.addRow()` - the entry row's starting point. A
473
479
  * function is called per added row (fresh timestamps, empty arrays).
480
+ * `edit.addRow(values)` overrides this key by key for that one row.
474
481
  */
475
482
  newRowDefaults?: TData | (() => TData);
476
483
  /**
@@ -498,12 +505,12 @@ type TMDataGridEditingCallbacks<TData extends RowData> = {
498
505
  * | `"cell"` | Enter, Tab, blur - Sheets | Escape |
499
506
  * | `"cellConfirm"` | ✓ or Enter only; blur keeps the draft | ✕ or Escape |
500
507
  * | `"row"` | Save in the edit lane, or Ctrl+Enter | Cancel, or Escape |
501
- * | `"draft"` | `edit.submitAll()` | `edit.cancelAll()` |
508
+ * | `"draft"` | `edit.commit(rowId)` into the draft store, `edit.saveDrafts()` out | `edit.cancelAll()` |
502
509
  *
503
510
  * Setting `editing` makes `getRowId` required - drafts are keyed by row id,
504
511
  * and the index fallback would name a different record after any sort - and
505
- * `onCommitDrafts` exists only under `mode: "draft"`, the one mode whose
506
- * `submitAll` calls it.
512
+ * `onSaveDrafts` exists only under `mode: "draft"`, the one mode with a draft
513
+ * store to save.
507
514
  *
508
515
  * The object may be written inline: the callbacks are read through a ref
509
516
  * every render, so its identity does not matter.
@@ -520,28 +527,49 @@ export type TMDataGridEditingOptions<TData extends RowData> =
520
527
  | {
521
528
  mode: "draft";
522
529
  /**
523
- * Draft mode's save, called once by `edit.submitAll()` with every
524
- * valid dirty row. Without it, `submitAll` falls back to the per-row
525
- * {@link TMDataGridEditingCallbacks.onCommit} loop. Rows failing
526
- * validation stay open either way; a rejection keeps every draft.
530
+ * Draft mode's save: called once by `edit.saveDrafts()` with the
531
+ * whole draft store - committed edits, added rows and deletion
532
+ * marks - so a server can apply it as one transaction. Without it,
533
+ * `saveDrafts` falls back to the per-row
534
+ * {@link TMDataGridEditingCallbacks.onCommit} loop.
535
+ *
536
+ * Rows still open are not in the payload and stay open. Returning
537
+ * nothing saves the whole store and throwing saves none of it;
538
+ * return a {@link TMDataGridSaveDraftsResult} to save part of it.
539
+ */
540
+ onSaveDrafts?: (
541
+ args: TMDataGridSaveDraftsArgs<TData>,
542
+ ) =>
543
+ | void
544
+ | TMDataGridSaveDraftsResult
545
+ | Promise<void | TMDataGridSaveDraftsResult>;
546
+ /**
547
+ * @deprecated Renamed to {@link onSaveDrafts} - it fires when the
548
+ * draft store is saved, not when a row commits into it. Still
549
+ * honoured; removed in a later beta.
527
550
  */
528
551
  onCommitDrafts?: (
529
- args: TMDataGridEditCommitDraftsArgs<TData>,
530
- ) => void | Promise<void>;
552
+ args: TMDataGridSaveDraftsArgs<TData>,
553
+ ) =>
554
+ | void
555
+ | TMDataGridSaveDraftsResult
556
+ | Promise<void | TMDataGridSaveDraftsResult>;
531
557
  /**
532
- * Keep confirmed entry rows pinned in the sticky entry block until
533
- * Save all. Off by default: a confirmed row joins the scrolling
534
- * flow above the body rows instead - the block a row is *typed*
535
- * into is always sticky, but entered rows scroll, so entering many
536
- * cannot fill the viewport with sticky chrome.
558
+ * Keep committed entry rows pinned in the sticky entry block until
559
+ * the draft store is saved. Off by default: a committed row joins
560
+ * the scrolling flow above the body rows instead - the block a row
561
+ * is *typed* into is always sticky, but committed rows scroll, so
562
+ * committing many cannot fill the viewport with sticky chrome.
537
563
  */
538
564
  newRowsSticky?: boolean;
539
565
  }
540
566
  | {
541
567
  mode: Exclude<TMDataGridEditMode, "draft">;
542
- /** Only `"draft"`'s `submitAll` ever calls it - see the other branch. */
568
+ /** Only `"draft"` has a draft store to save - see the other branch. */
569
+ onSaveDrafts?: never;
570
+ /** @deprecated See {@link onSaveDrafts}. */
543
571
  onCommitDrafts?: never;
544
- /** Confirmed entry rows exist only under `"draft"` - see there. */
572
+ /** Committed entry rows exist only under `"draft"` - see there. */
545
573
  newRowsSticky?: never;
546
574
  }
547
575
  );
@@ -1004,6 +1032,58 @@ export function useTMDataGrid<TData extends RowData>({
1004
1032
  const initialGrouping =
1005
1033
  persistedState.grouping ?? options.initialState?.grouping ?? [];
1006
1034
 
1035
+ // Current grouping state, feeding the tree column's entry in a controlled
1036
+ // `columnVisibility` (below). A ref, not state: it is only read while
1037
+ // building the options, and the store change that updates it re-renders the
1038
+ // hook anyway. A controlled `grouping` takes precedence over the persisted
1039
+ // one; grouping held in an external atom is not readable here and is
1040
+ // corrected by the effect below with one extra write on mount.
1041
+ const groupingActiveRef = useRef(
1042
+ (options.state?.grouping ?? initialGrouping).length > 0,
1043
+ );
1044
+
1045
+ // Removes keys set to `undefined` before TanStack writes them into the
1046
+ // slice atoms; such a key means the slice is not controlled.
1047
+ const consumerState = withoutUndefinedSlices(options.state);
1048
+
1049
+ // A controlled `columnVisibility` replaces the whole map on every options
1050
+ // sync, including the grid's own entries, so the tree column's entry must be
1051
+ // re-applied here. Entries for the control columns are removed, as they are
1052
+ // for `initialState`: their visibility follows the feature options, not the
1053
+ // visibility map.
1054
+ const controlledColumnVisibility = consumerState?.columnVisibility;
1055
+ const requestedState =
1056
+ consumerState !== undefined && controlledColumnVisibility !== undefined
1057
+ ? {
1058
+ ...consumerState,
1059
+ columnVisibility: {
1060
+ ...withoutControlColumnVisibility(controlledColumnVisibility),
1061
+ ...(groupColumnEnabled
1062
+ ? { [GROUP_COLUMN_ID]: groupingActiveRef.current }
1063
+ : {}),
1064
+ },
1065
+ }
1066
+ : consumerState;
1067
+
1068
+ // Controlled state is synced to the table on every render and compared by
1069
+ // identity, so a `state` object built in the consumer's render body would
1070
+ // cause an infinite render loop. Unchanged slices are forwarded with the
1071
+ // previous render's identity instead - see controlledState.ts.
1072
+ const controlledStateRef = useRef<Partial<TableState<TMDataGridFeatures>>>(
1073
+ undefined,
1074
+ );
1075
+ // Exception: the grouping workaround below republishes `columnOrder` and
1076
+ // `columnVisibility` with unchanged contents and a new identity to repair
1077
+ // table-core's missing memo deps - the exact write stabilization cancels.
1078
+ // After it runs, one render forwards the controlled state unstabilized so
1079
+ // the new identities reach the atoms. A single render cannot loop.
1080
+ const republishControlledStateRef = useRef(false);
1081
+ const controlledState = republishControlledStateRef.current
1082
+ ? requestedState
1083
+ : stabilizeControlledState(requestedState, controlledStateRef.current);
1084
+ republishControlledStateRef.current = false;
1085
+ controlledStateRef.current = controlledState;
1086
+
1007
1087
  const table = useTable({
1008
1088
  columnResizeMode: "onChange",
1009
1089
  enableSorting: true,
@@ -1042,6 +1122,8 @@ export function useTMDataGrid<TData extends RowData>({
1042
1122
  : options.enableRowPinning === true),
1043
1123
  features: tmDataGridFeatures,
1044
1124
  columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
1125
+ // The stabilized controlled state; `undefined` when nothing is controlled.
1126
+ state: controlledState,
1045
1127
  initialState: {
1046
1128
  ...options.initialState,
1047
1129
  ...persistedState,
@@ -1109,16 +1191,22 @@ export function useTMDataGrid<TData extends RowData>({
1109
1191
  editing?.isRowEditable as TMDataGridEditEngineContext["isRowEditable"],
1110
1192
  onEditCommit:
1111
1193
  editing?.onCommit as TMDataGridEditEngineContext["onEditCommit"],
1112
- onEditCommitDrafts:
1113
- editing?.onCommitDrafts as TMDataGridEditEngineContext["onEditCommitDrafts"],
1194
+ // The deprecated name still works; the new one wins if both are set.
1195
+ onSaveDrafts: (editing?.onSaveDrafts ??
1196
+ editing?.onCommitDrafts) as TMDataGridEditEngineContext["onSaveDrafts"],
1114
1197
  newRowDefaults:
1115
1198
  editing?.newRowDefaults as TMDataGridEditEngineContext["newRowDefaults"],
1116
1199
  onRowAdd: editing?.onRowAdd as TMDataGridEditEngineContext["onRowAdd"],
1117
1200
  onRowDelete:
1118
1201
  editing?.onRowDelete as TMDataGridEditEngineContext["onRowDelete"],
1119
1202
  };
1120
- const [edit] = useState(() =>
1121
- createEditEngine(() => editContextRef.current),
1203
+ // The engine is erased; the row type comes back on the way out, which is
1204
+ // what makes `edit.addRow(values)` check against `TData`.
1205
+ const [edit] = useState(
1206
+ () =>
1207
+ createEditEngine(
1208
+ () => editContextRef.current,
1209
+ ) as unknown as TMDataGridEditApi<TData>,
1122
1210
  );
1123
1211
 
1124
1212
  // Switching modes mid-flight drops every draft: the policies disagree about
@@ -1143,6 +1231,20 @@ export function useTMDataGrid<TData extends RowData>({
1143
1231
  // eslint-disable-next-line react-hooks/exhaustive-deps
1144
1232
  }, []);
1145
1233
 
1234
+ // A controlled slice without its `onXChange` cannot change: TanStack routes
1235
+ // every write through the callback, and the next options sync restores the
1236
+ // consumer's value. Warned once in development; `initialState` is the option
1237
+ // for a starting value.
1238
+ useEffect(() => {
1239
+ for (const { slice, handler } of findFrozenStateSlices(options)) {
1240
+ console.warn(
1241
+ `TMDataGrid: state.${slice} is controlled but no ${handler} was passed - the slice cannot change. Add ${handler}, or use initialState.${slice} for a starting value.`,
1242
+ );
1243
+ }
1244
+ // A mount-time contract, like the check above.
1245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1246
+ }, []);
1247
+
1146
1248
  // Two things have to happen whenever `grouping` changes.
1147
1249
  //
1148
1250
  // One: the tree column appears with the first grouped column and goes away
@@ -1182,10 +1284,40 @@ export function useTMDataGrid<TData extends RowData>({
1182
1284
  useEffect(() => {
1183
1285
  if (!groupColumnEnabled) return;
1184
1286
  let previousGrouping = table.store.state.grouping;
1287
+ // Update the ref before any write: it feeds the visibility injection
1288
+ // above, and a write below re-renders the hook. With a stale ref the
1289
+ // injection would restore the old value on every render. This also
1290
+ // corrects the mount value when an external atom owns `grouping`, which
1291
+ // the ref's initializer cannot read.
1292
+ groupingActiveRef.current = previousGrouping.length > 0;
1293
+
1294
+ // The tree column's entry in `initialState` only reaches a slice the
1295
+ // table owns. With `columnVisibility` in an external atom the entry is
1296
+ // missing, and a missing entry means visible: the tree column would
1297
+ // render in an ungrouped grid. The entry is seeded here through the table
1298
+ // API instead, so the write reaches whichever store owns the slice. No-op
1299
+ // when the entry is already correct. `?.`: an external atom can hold
1300
+ // `undefined`.
1301
+ if (
1302
+ table.store.state.columnVisibility?.[GROUP_COLUMN_ID] !==
1303
+ (previousGrouping.length > 0)
1304
+ ) {
1305
+ table.setColumnVisibility((old) => ({
1306
+ ...old,
1307
+ [GROUP_COLUMN_ID]: previousGrouping.length > 0,
1308
+ }));
1309
+ }
1185
1310
 
1186
1311
  const subscription = table.store.subscribe((state) => {
1187
1312
  if (state.grouping === previousGrouping) return;
1188
1313
  previousGrouping = state.grouping;
1314
+ // Keeps the entry injected into a controlled `columnVisibility` (see
1315
+ // requestedState) in sync with grouping.
1316
+ groupingActiveRef.current = state.grouping.length > 0;
1317
+ // On a controlled slice the writes below round-trip through the
1318
+ // consumer's handler; the next render must forward them unstabilized.
1319
+ // See republishControlledStateRef.
1320
+ republishControlledStateRef.current = true;
1189
1321
 
1190
1322
  table.setColumnVisibility((old) => ({
1191
1323
  ...old,