@dxos/react-ui-table 0.10.0 → 0.11.1

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 (110) hide show
  1. package/dist/lib/chunk-types.mjs +65 -0
  2. package/dist/lib/chunk-types.mjs.map +1 -0
  3. package/dist/lib/chunk-useTableModel.mjs +1595 -0
  4. package/dist/lib/chunk-useTableModel.mjs.map +1 -0
  5. package/dist/lib/index.mjs +1133 -0
  6. package/dist/lib/index.mjs.map +1 -0
  7. package/dist/lib/testing.mjs +88 -0
  8. package/dist/lib/testing.mjs.map +1 -0
  9. package/dist/lib/translations.mjs +30 -0
  10. package/dist/lib/translations.mjs.map +1 -0
  11. package/dist/lib/types.mjs +2 -0
  12. package/dist/types/src/components/Table/DynamicTable.d.ts +4 -1
  13. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  14. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +3 -1
  15. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/Table/Relations.stories.d.ts +1 -0
  17. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -1
  18. package/dist/types/src/components/Table/Table.d.ts +3 -9
  19. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  20. package/dist/types/src/components/Table/Table.stories.d.ts +2 -0
  21. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  22. package/dist/types/src/components/Table/TableContent.d.ts +1 -1
  23. package/dist/types/src/components/Table/TableContent.d.ts.map +1 -1
  24. package/dist/types/src/components/Table/TableContext.d.ts +10 -0
  25. package/dist/types/src/components/Table/TableContext.d.ts.map +1 -0
  26. package/dist/types/src/components/Table/TableToolbar.d.ts +1 -1
  27. package/dist/types/src/components/Table/TableToolbar.d.ts.map +1 -1
  28. package/dist/types/src/components/Table/index.d.ts +1 -0
  29. package/dist/types/src/components/Table/index.d.ts.map +1 -1
  30. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  31. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  32. package/dist/types/src/components/testing.d.ts +1 -1
  33. package/dist/types/src/components/testing.d.ts.map +1 -1
  34. package/dist/types/src/hooks/useProjectionModel.d.ts +1 -1
  35. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -1
  36. package/dist/types/src/hooks/useTableModel.d.ts +8 -1
  37. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  38. package/dist/types/src/model/modal-controller.d.ts +1 -1
  39. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  40. package/dist/types/src/model/selection-model.d.ts +1 -1
  41. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  42. package/dist/types/src/model/table-model.d.ts +42 -7
  43. package/dist/types/src/model/table-model.d.ts.map +1 -1
  44. package/dist/types/src/model/table-presentation.d.ts +20 -5
  45. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  46. package/dist/types/src/model/table-view-state.d.ts +5 -1
  47. package/dist/types/src/model/table-view-state.d.ts.map +1 -1
  48. package/dist/types/src/types/index.d.ts.map +1 -1
  49. package/dist/types/src/util/dynamic-table.d.ts +1 -1
  50. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  51. package/dist/types/src/util/table-controls.d.ts +5 -3
  52. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  53. package/dist/types/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +48 -50
  55. package/src/components/Table/DynamicTable.stories.tsx +59 -1
  56. package/src/components/Table/DynamicTable.tsx +16 -8
  57. package/src/components/Table/Relations.stories.tsx +2 -2
  58. package/src/components/Table/Table.stories.tsx +76 -2
  59. package/src/components/Table/Table.tsx +3 -15
  60. package/src/components/Table/TableContent.tsx +6 -5
  61. package/src/components/Table/TableContext.ts +21 -0
  62. package/src/components/Table/index.ts +2 -0
  63. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +2 -3
  64. package/src/components/TableCellEditor/TableCellEditor.tsx +27 -22
  65. package/src/components/testing.ts +1 -1
  66. package/src/hooks/useProjectionModel.ts +1 -1
  67. package/src/hooks/useTableModel.ts +18 -6
  68. package/src/model/modal-controller.ts +1 -1
  69. package/src/model/selection-model.ts +1 -1
  70. package/src/model/table-model.test.ts +3 -3
  71. package/src/model/table-model.ts +148 -31
  72. package/src/model/table-presentation.test.ts +59 -1
  73. package/src/model/table-presentation.ts +171 -81
  74. package/src/model/table-view-state.ts +2 -2
  75. package/src/testing/useTestTableModel.ts +1 -1
  76. package/src/types/index.ts +4 -0
  77. package/src/util/dynamic-table.test.ts +1 -1
  78. package/src/util/dynamic-table.ts +1 -1
  79. package/src/util/table-buttons.ts +1 -1
  80. package/src/util/table-controls.ts +18 -10
  81. package/dist/lib/browser/chunk-J5LGTIGS.mjs +0 -10
  82. package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +0 -7
  83. package/dist/lib/browser/chunk-K4J2CKTZ.mjs +0 -1751
  84. package/dist/lib/browser/chunk-K4J2CKTZ.mjs.map +0 -7
  85. package/dist/lib/browser/chunk-VV3P3ADH.mjs +0 -67
  86. package/dist/lib/browser/chunk-VV3P3ADH.mjs.map +0 -7
  87. package/dist/lib/browser/index.mjs +0 -1416
  88. package/dist/lib/browser/index.mjs.map +0 -7
  89. package/dist/lib/browser/meta.json +0 -1
  90. package/dist/lib/browser/testing/index.mjs +0 -115
  91. package/dist/lib/browser/testing/index.mjs.map +0 -7
  92. package/dist/lib/browser/translations.mjs +0 -39
  93. package/dist/lib/browser/translations.mjs.map +0 -7
  94. package/dist/lib/browser/types/index.mjs +0 -8
  95. package/dist/lib/browser/types/index.mjs.map +0 -7
  96. package/dist/lib/node-esm/chunk-E6V274ZW.mjs +0 -1753
  97. package/dist/lib/node-esm/chunk-E6V274ZW.mjs.map +0 -7
  98. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  99. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  100. package/dist/lib/node-esm/chunk-RPEWNOZZ.mjs +0 -68
  101. package/dist/lib/node-esm/chunk-RPEWNOZZ.mjs.map +0 -7
  102. package/dist/lib/node-esm/index.mjs +0 -1417
  103. package/dist/lib/node-esm/index.mjs.map +0 -7
  104. package/dist/lib/node-esm/meta.json +0 -1
  105. package/dist/lib/node-esm/testing/index.mjs +0 -116
  106. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  107. package/dist/lib/node-esm/translations.mjs +0 -40
  108. package/dist/lib/node-esm/translations.mjs.map +0 -7
  109. package/dist/lib/node-esm/types/index.mjs +0 -9
  110. package/dist/lib/node-esm/types/index.mjs.map +0 -7
@@ -3,10 +3,10 @@
3
3
  //
4
4
 
5
5
  import { RegistryContext } from '@effect-atom/atom-react';
6
- import { useContext, useEffect, useMemo, useState } from 'react';
6
+ import { useContext, useEffect, useRef, useState } from 'react';
7
7
 
8
8
  import { type Database, Obj } from '@dxos/echo';
9
- import { useSelection, useSelectionActions, useViewStateManagerOptional } from '@dxos/react-ui-attention';
9
+ import { useManagerOptional, useSelection, useSelectionActions } from '@dxos/react-ui-attention';
10
10
  import { type ProjectionModel } from '@dxos/schema';
11
11
 
12
12
  import {
@@ -24,6 +24,13 @@ export type UseTableModelProps<T extends TableRow = TableRow> = {
24
24
  db?: Database.Database;
25
25
  rows?: T[];
26
26
  rowActions?: TableRowAction[];
27
+ /**
28
+ * Rows to select when the model is built. Overrides the selection read from the view state for
29
+ * the table object — pass this when the surrounding container owns the selection under a
30
+ * different context id (e.g. a type article keyed by the type's URI), so switching into the
31
+ * table preserves what was already selected.
32
+ */
33
+ selection?: readonly string[];
27
34
  onSelectionChanged?: (selection: string[]) => void;
28
35
  onRowAction?: (actionId: string, data: T) => void;
29
36
  } & Pick<
@@ -38,14 +45,19 @@ export const useTableModel = <T extends TableRow = TableRow>({
38
45
  rows,
39
46
  rowActions,
40
47
  features,
48
+ selection,
41
49
  onSelectionChanged,
42
50
  onRowAction,
43
51
  ...props
44
52
  }: UseTableModelProps<T>): TableModel<T> | undefined => {
45
53
  const registry = useContext(RegistryContext);
46
- const viewState = useViewStateManagerOptional();
54
+ const viewState = useManagerOptional();
47
55
  const selected = useSelection(object && Obj.getURI(object), 'multi');
48
- const initialSelection = useMemo(() => selected, [object]);
56
+ // Read at construction time, not frozen into the effect's deps. The model is rebuilt whenever any
57
+ // of its inputs change identity, and each rebuild re-seeds from here — a frozen snapshot would
58
+ // reset the selection to whatever it was when the table mounted, discarding the user's clicks.
59
+ const initialSelection = useRef<readonly string[]>(selection ?? selected);
60
+ initialSelection.current = selection ?? selected;
49
61
 
50
62
  const [model, setModel] = useState<TableModel<T>>();
51
63
  useEffect(() => {
@@ -64,7 +76,7 @@ export const useTableModel = <T extends TableRow = TableRow>({
64
76
  change: createEchoChangeCallback<T>(object),
65
77
  features,
66
78
  rowActions,
67
- initialSelection,
79
+ initialSelection: [...initialSelection.current],
68
80
  onRowAction,
69
81
  ...props,
70
82
  });
@@ -77,7 +89,7 @@ export const useTableModel = <T extends TableRow = TableRow>({
77
89
  void model?.close();
78
90
  };
79
91
  // TODO(burdon): Trigger if callbacks change?
80
- }, [registry, viewState, object, projection, features, rowActions, initialSelection]);
92
+ }, [registry, viewState, object, projection, features, rowActions]);
81
93
 
82
94
  // Update data when rows change.
83
95
  useEffect(() => {
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
5
+ import { Atom, type Registry } from '@effect-atom/atom';
6
6
  import { type MutableRefObject, type RefObject } from 'react';
7
7
 
8
8
  export type ColumnSettingsMode = { type: 'create' } | { type: 'edit'; fieldId: string };
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
5
+ import { Atom, type Registry } from '@effect-atom/atom';
6
6
 
7
7
  import { Resource } from '@dxos/context';
8
8
 
@@ -2,13 +2,13 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Registry } from '@effect-atom/atom-react';
5
+ import { Registry } from '@effect-atom/atom';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
8
8
 
9
9
  import { DXN, Filter, JsonSchema, Query, Type } from '@dxos/echo';
10
10
  import { createEchoSchema } from '@dxos/echo/testing';
11
- import { LocalBackend, MemoryBackend, ViewStateManager } from '@dxos/react-ui-attention';
11
+ import { LocalBackend, MemoryBackend, ViewState } from '@dxos/react-ui-attention';
12
12
  import { ProjectionModel, ViewModel, createDirectChangeCallback } from '@dxos/schema';
13
13
 
14
14
  import { Table } from '../types';
@@ -137,7 +137,7 @@ describe('TableModel sort view state', () => {
137
137
  const registry = Registry.make();
138
138
  const local = new LocalBackend({ registry, storage });
139
139
  disposables.push(local);
140
- const viewState = new ViewStateManager({ registry, backends: { memory: new MemoryBackend(), local } });
140
+ const viewState = new ViewState.Manager({ registry, backends: { memory: new MemoryBackend(), local } });
141
141
  const projection = new ProjectionModel({
142
142
  registry,
143
143
  view,
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
5
+ import { Atom, type Registry } from '@effect-atom/atom';
6
6
 
7
7
  import { Resource } from '@dxos/context';
8
8
  import { type Database, Format, Obj, Order, Query, type QueryAST, Ref, Type, type View } from '@dxos/echo';
@@ -12,14 +12,16 @@ import { SchemaEx } from '@dxos/effect';
12
12
  import { invariant } from '@dxos/invariant';
13
13
  import { EntityId } from '@dxos/keys';
14
14
  import { type Label } from '@dxos/react-ui';
15
- import { type ViewStateManager } from '@dxos/react-ui-attention';
16
- import { formatForEditing, parseValue } from '@dxos/react-ui-form';
15
+ import { ViewState } from '@dxos/react-ui-attention';
16
+ import { parseValue } from '@dxos/react-ui-form';
17
17
  import {
18
18
  type DxGridAxisMeta,
19
+ type DxGridPlane,
19
20
  type DxGridPlanePosition,
20
21
  type DxGridPlaneRange,
21
22
  type DxGridPosition,
22
23
  } from '@dxos/react-ui-grid';
24
+ import { formatForEditing } from '@dxos/schema';
23
25
  import { type ProjectionModel, type PropertyType, type ValidationError, validateSchema } from '@dxos/schema';
24
26
 
25
27
  import { type Table } from '../types';
@@ -88,12 +90,18 @@ export type TableFeatures = {
88
90
  selection: { enabled: boolean; mode?: SelectionMode };
89
91
  dataEditable: boolean;
90
92
  schemaEditable: boolean;
93
+ /**
94
+ * Number of leading data columns to pin (freeze) so they remain visible while the remaining
95
+ * columns scroll horizontally. Pinned columns are rendered in the grid's frozen-column planes.
96
+ */
97
+ pinColumns: number;
91
98
  };
92
99
 
93
100
  const defaultFeatures: TableFeatures = {
94
101
  selection: { enabled: true, mode: 'multiple' },
95
102
  dataEditable: false,
96
103
  schemaEditable: false,
104
+ pinColumns: 0,
97
105
  };
98
106
 
99
107
  export type InsertRowResult = 'draft' | 'final';
@@ -108,7 +116,7 @@ export type TableModelProps<T extends TableRow = TableRow> = {
108
116
  * stored in the `tableSortAspect` view state keyed by the table URI; otherwise it falls back to an
109
117
  * ephemeral atom (e.g. isolated stories/tests with no `ViewStateProvider`).
110
118
  */
111
- viewState?: ViewStateManager;
119
+ viewState?: ViewState.Manager;
112
120
  /**
113
121
  * Callbacks to wrap mutations in Obj.update().
114
122
  * Use createEchoChangeCallback() for ECHO-backed objects or createDirectChangeCallback() for plain objects.
@@ -148,9 +156,9 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
148
156
  private readonly _rows: Atom.Writable<T[]>;
149
157
  private readonly _draftRows: Atom.Writable<DraftRow<T>[]>;
150
158
  // Optional per-context UI state manager; when present the column sort is view-state-backed.
151
- private readonly _viewState?: ViewStateManager;
159
+ private readonly _viewState?: ViewState.Manager;
152
160
  // Per-user sort state - backed by the `tableSortAspect` view state (keyed by table URI) when a
153
- // ViewStateManager is provided, else an ephemeral atom. Changes are local until saved to the view.
161
+ // ViewState.Manager is provided, else an ephemeral atom. Changes are local until saved to the view.
154
162
  private readonly _inMemorySort: Atom.Writable<FieldSortType | undefined>;
155
163
  // Derived atom for persisted sort from view.query.ast.
156
164
  private readonly _persistedSort: Atom.Atom<FieldSortType | undefined>;
@@ -210,7 +218,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
210
218
  this._rows = Atom.make<T[]>([]);
211
219
  this._draftRows = Atom.make<DraftRow<T>[]>([]);
212
220
  // View-state-backed atom survives remount/reload (local backend, keyed by table URI); the
213
- // ephemeral fallback preserves prior behaviour when no ViewStateManager is supplied.
221
+ // ephemeral fallback preserves prior behaviour when no ViewState.Manager is supplied.
214
222
  this._inMemorySort = this._viewState
215
223
  ? this._viewState.atom(tableSortAspect, this.id)
216
224
  : Atom.make<FieldSortType | undefined>(undefined);
@@ -353,6 +361,24 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
353
361
  return this._cellUpdateCounter;
354
362
  }
355
363
 
364
+ /**
365
+ * Reports the grid's currently-visible cell range (called by the presentation from `getCells`). Drives the
366
+ * per-row subscriptions that keep cells reactive to external mutations, so it must be wired for the grid to
367
+ * reflect edits made outside it. Guarded so identical ranges don't churn the subscriptions.
368
+ */
369
+ public setVisibleRange(range: DxGridPlaneRange): void {
370
+ const current = this._registry.get(this._visibleRange);
371
+ if (
372
+ current.start.row === range.start.row &&
373
+ current.end.row === range.end.row &&
374
+ current.start.col === range.start.col &&
375
+ current.end.col === range.end.col
376
+ ) {
377
+ return;
378
+ }
379
+ this._registry.set(this._visibleRange, range);
380
+ }
381
+
356
382
  /**
357
383
  * Change a row using the configured change callback.
358
384
  * Use this instead of directly mutating to ensure consistent mutation handling.
@@ -365,6 +391,57 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
365
391
  return this._features;
366
392
  }
367
393
 
394
+ /**
395
+ * Number of leading data columns pinned (frozen), clamped to the available field count.
396
+ */
397
+ public get pinColumns(): number {
398
+ const fieldCount = this.getColumnCount();
399
+ return Math.max(0, Math.min(this._features.pinColumns, fieldCount));
400
+ }
401
+
402
+ /**
403
+ * Number of frozen-start columns preceding the pinned data columns (the selection column, if enabled).
404
+ */
405
+ public get selectionColumns(): number {
406
+ return this._features.selection.enabled ? 1 : 0;
407
+ }
408
+
409
+ /**
410
+ * Total number of columns rendered in the `frozenColsStart` plane: the selection column (if enabled)
411
+ * plus the pinned data columns. Single source of truth for the frozen-start boundary.
412
+ */
413
+ public get frozenColsStart(): number {
414
+ return this.selectionColumns + this.pinColumns;
415
+ }
416
+
417
+ /**
418
+ * Maps a plane-local column index to a field index for the data-bearing planes.
419
+ * Returns undefined for non-data columns (e.g. the selection or action columns).
420
+ *
421
+ * Column layout across planes:
422
+ * - `frozenColsStart`: [selection?] then the first `pinColumns` data fields.
423
+ * - `grid` / `frozenRowsStart` / `frozenRowsEnd`: data fields starting at index `pinColumns`.
424
+ */
425
+ public getFieldIndex(plane: DxGridPlane, planeCol: number): number | undefined {
426
+ switch (plane) {
427
+ case 'frozenColsStart':
428
+ case 'fixedStartStart':
429
+ case 'fixedEndStart': {
430
+ const dataCol = planeCol - this.selectionColumns;
431
+ if (dataCol < 0 || dataCol >= this.pinColumns) {
432
+ return undefined;
433
+ }
434
+ return dataCol;
435
+ }
436
+ case 'grid':
437
+ case 'frozenRowsStart':
438
+ case 'frozenRowsEnd':
439
+ return this.pinColumns + planeCol;
440
+ default:
441
+ return undefined;
442
+ }
443
+ }
444
+
368
445
  /**
369
446
  * Gets the current sort state.
370
447
  * Priority: in-memory sort (local changes) > persisted sort (from query AST).
@@ -420,14 +497,38 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
420
497
  // The projection.fields atom handles subscriptions to view changes internally.
421
498
  this._columnMeta = Atom.make((get) => {
422
499
  const fields = get(this._projection.fields);
423
- const meta = Object.fromEntries(
424
- fields.map((field, index: number) => [index, { size: this.table.sizes[field.path] ?? 256, resizeable: true }]),
425
- );
500
+ const pin = this.pinColumns;
501
+ const selectionColumns = this.selectionColumns;
502
+
503
+ const fieldSize = (field: (typeof fields)[number]) => ({
504
+ size: this.table.sizes[field.path] ?? 256,
505
+ resizeable: true,
506
+ });
507
+
508
+ // The scrolling `grid` plane holds the un-pinned fields, re-keyed to plane-local indices.
509
+ const grid = Object.fromEntries(fields.slice(pin).map((field, index: number) => [index, fieldSize(field)]));
510
+
511
+ // The `frozenColsStart` plane holds the selection column (if enabled) followed by the pinned fields.
512
+ const frozenColsStart: Record<number, { size: number; resizeable: boolean }> = {};
513
+ if (selectionColumns > 0) {
514
+ frozenColsStart[0] = {
515
+ size: 32,
516
+ resizeable: false,
517
+ };
518
+ }
519
+ fields.slice(0, pin).forEach((field, index: number) => {
520
+ frozenColsStart[selectionColumns + index] = fieldSize(field);
521
+ });
426
522
 
427
523
  return {
428
- grid: meta,
429
- frozenColsStart: { 0: { size: 30, resizeable: false } },
430
- frozenColsEnd: { 0: { size: 32, resizeable: false } },
524
+ grid,
525
+ frozenColsStart,
526
+ frozenColsEnd: {
527
+ 0: {
528
+ size: 32,
529
+ resizeable: false,
530
+ },
531
+ },
431
532
  };
432
533
  });
433
534
  }
@@ -442,9 +543,10 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
442
543
  // Track row subscriptions for cleanup.
443
544
  const rowSubscriptions = new Map<string, () => void>();
444
545
 
445
- // Subscribe to visible range changes to set up row subscriptions.
446
- const visibleRangeUnsubscribe = this._registry.subscribe(this._visibleRange, () => {
447
- // Unsubscribe from old row subscriptions.
546
+ // (Re)subscribe to each visible row's mutations so external edits (e.g. from a companion form) bump the
547
+ // cell-update counter and repaint the affected cells. Must re-run when either the visible range changes (scroll)
548
+ // or the row set changes (data load / sort), since rows arrive asynchronously after the range is first reported.
549
+ const subscribeVisibleRows = () => {
448
550
  for (const [id, unsub] of rowSubscriptions) {
449
551
  unsub();
450
552
  rowSubscriptions.delete(id);
@@ -453,21 +555,25 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
453
555
  const { start, end } = this._registry.get(this._visibleRange);
454
556
  const sortedRows = this._registry.get(this._sortedRows);
455
557
 
456
- // Subscribe to each visible row's changes.
457
558
  for (let row = start.row; row <= end.row && row < sortedRows.length; row++) {
458
559
  const obj = sortedRows[row];
459
560
  if (Obj.isObject(obj) && !rowSubscriptions.has(obj.id)) {
561
+ const cell = { row, col: start.col, plane: 'grid' as const };
460
562
  const unsub = Obj.subscribe(obj, () => {
461
563
  // Increment cell update counter to notify UI of changes.
462
564
  this._registry.set(this._cellUpdateCounter, this._registry.get(this._cellUpdateCounter) + 1);
463
- this._onCellUpdate?.({ row, col: start.col, plane: 'grid' });
565
+ this._onCellUpdate?.(cell);
464
566
  });
465
567
  rowSubscriptions.set(obj.id, unsub);
466
568
  }
467
569
  }
468
- });
570
+ };
571
+
572
+ const visibleRangeUnsubscribe = this._registry.subscribe(this._visibleRange, subscribeVisibleRows);
573
+ const sortedRowsUnsubscribe = this._registry.subscribe(this._sortedRows, subscribeVisibleRows);
469
574
  this._ctx.onDispose(() => {
470
575
  visibleRangeUnsubscribe();
576
+ sortedRowsUnsubscribe();
471
577
  for (const unsub of rowSubscriptions.values()) {
472
578
  unsub();
473
579
  }
@@ -625,11 +731,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
625
731
  public getCellData = (cell: DxGridPosition): any => {
626
732
  const { col, row, plane } = cell;
627
733
  const fields = this._projection?.getFields() ?? [];
628
- if (col < 0 || col >= fields.length) {
734
+ const fieldIndex = this.getFieldIndex(plane, col);
735
+ if (fieldIndex === undefined || fieldIndex < 0 || fieldIndex >= fields.length) {
629
736
  return undefined;
630
737
  }
631
738
 
632
- const field = fields[col];
739
+ const field = fields[fieldIndex];
633
740
  let value: any;
634
741
 
635
742
  if (plane === 'frozenRowsEnd') {
@@ -671,11 +778,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
671
778
 
672
779
  public validateCellData = async ({ col, row, plane }: DxGridPosition, value: any): Promise<ValidationResult> => {
673
780
  const fields = this._projection?.getFields() ?? [];
674
- if (col < 0 || col >= fields.length) {
781
+ const fieldIndex = this.getFieldIndex(plane, col);
782
+ if (fieldIndex === undefined || fieldIndex < 0 || fieldIndex >= fields.length) {
675
783
  return { valid: false, error: 'Invalid column index' };
676
784
  }
677
785
 
678
- const field = fields[col];
786
+ const field = fields[fieldIndex];
679
787
  const { props } = this._projection.getFieldProjection(field.id);
680
788
  const transformedValue = editorTextToCellValue(props, value);
681
789
 
@@ -727,11 +835,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
727
835
  public setCellData = (cell: DxGridPosition, value: any): void => {
728
836
  const { col, row, plane } = cell;
729
837
  const fields = this._projection?.getFields() ?? [];
730
- if (col < 0 || col >= fields.length) {
838
+ const fieldIndex = this.getFieldIndex(plane, col);
839
+ if (fieldIndex === undefined || fieldIndex < 0 || fieldIndex >= fields.length) {
731
840
  return;
732
841
  }
733
842
 
734
- const field = fields[col];
843
+ const field = fields[fieldIndex];
735
844
  const { props } = this._projection.getFieldProjection(field.id);
736
845
  const transformedValue = editorTextToCellValue(props, value);
737
846
 
@@ -762,9 +871,16 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
762
871
  * @param {DxGridPlanePosition} position - The position of the cell to update.
763
872
  * @param {(value: any) => any} update - A function that takes the current value and returns the updated value.
764
873
  */
765
- public updateCellData({ col, row }: DxGridPlanePosition, update: (value: any) => any): void {
874
+ public updateCellData(
875
+ { col, row, plane = 'grid' }: DxGridPlanePosition & { plane?: DxGridPlane },
876
+ update: (value: any) => any,
877
+ ): void {
766
878
  const fields = this._projection?.getFields() ?? [];
767
- const field = fields[col];
879
+ const fieldIndex = this.getFieldIndex(plane, col);
880
+ if (fieldIndex === undefined || fieldIndex < 0 || fieldIndex >= fields.length) {
881
+ return;
882
+ }
883
+ const field = fields[fieldIndex];
768
884
  const sortedRows = this._registry.get(this._sortedRows);
769
885
  const rowData = sortedRows[row];
770
886
 
@@ -821,11 +937,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
821
937
  // Resize
822
938
  //
823
939
 
824
- public setColumnWidth(columnIndex: number, width: number): void {
940
+ public setColumnWidth(columnIndex: number, width: number, plane: DxGridPlane = 'grid'): void {
825
941
  const fields = this._projection?.getFields() ?? [];
826
- if (columnIndex < fields.length) {
942
+ const fieldIndex = this.getFieldIndex(plane, columnIndex);
943
+ if (fieldIndex !== undefined && fieldIndex < fields.length) {
827
944
  const newWidth = Math.max(0, width);
828
- const field = fields[columnIndex];
945
+ const field = fields[fieldIndex];
829
946
  if (field) {
830
947
  this._change.table((mutableTable) => {
831
948
  mutableTable.sizes[field.path] = newWidth;
@@ -866,7 +983,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
866
983
  }
867
984
 
868
985
  /**
869
- * Writes the per-user sort, routing through the ViewStateManager (so the `local` backend persists)
986
+ * Writes the per-user sort, routing through the ViewState.Manager (so the `local` backend persists)
870
987
  * when one is configured, otherwise updating the ephemeral atom directly.
871
988
  */
872
989
  private _writeSort(value: FieldSortType | undefined): void {
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Registry } from '@effect-atom/atom-react';
5
+ import { Registry } from '@effect-atom/atom';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import { beforeEach, describe, expect, it } from 'vitest';
8
8
 
@@ -84,6 +84,64 @@ describe('TablePresentation', () => {
84
84
  expect(cells['0,1']?.value).toBe('C');
85
85
  });
86
86
  });
87
+
88
+ describe('pinned columns', () => {
89
+ let registry: Registry.Registry;
90
+ let model: TableModel;
91
+ let presentation: TablePresentation;
92
+
93
+ beforeEach(async () => {
94
+ registry = Registry.make();
95
+ model = createTableModel(registry, { features: { selection: { enabled: false }, pinColumns: 1 } });
96
+ await model.open();
97
+ const rows: any[] = [
98
+ { id: '1', title: 'A', count: 1 },
99
+ { id: '2', title: 'B', count: 2 },
100
+ ];
101
+ model.setRows(rows);
102
+ presentation = new TablePresentation(registry, model);
103
+ });
104
+
105
+ it('renders the first data column in the frozenColsStart plane', () => {
106
+ const cells = presentation.getCells({ start: { row: 0, col: 0 }, end: { row: 1, col: 0 } }, 'frozenColsStart');
107
+ // Pinned field occupies plane-local column 0 (no selection column).
108
+ expect(cells['0,0']?.value).toBe('A');
109
+ expect(cells['0,1']?.value).toBe('B');
110
+ });
111
+
112
+ it('offsets the scrolling grid plane past the pinned columns', () => {
113
+ const cells = presentation.getCells({ start: { row: 0, col: 0 }, end: { row: 1, col: 0 } }, 'grid');
114
+ // The grid plane starts at the second field (count) since the first (title) is pinned.
115
+ expect(cells['0,0']?.value).toBe('1');
116
+ expect(cells['0,1']?.value).toBe('2');
117
+ });
118
+
119
+ it('renders the pinned column header in the fixedStartStart corner', () => {
120
+ const cells = presentation.getCells({ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }, 'fixedStartStart');
121
+ expect(cells['0,0']?.accessoryHtml).toContain('title');
122
+ });
123
+
124
+ it('maps plane-local columns to field indices', () => {
125
+ // frozenColsStart column 0 -> field 0 (pinned); grid column 0 -> field 1.
126
+ expect(model.getFieldIndex('frozenColsStart', 0)).toBe(0);
127
+ expect(model.getFieldIndex('frozenColsStart', 1)).toBeUndefined();
128
+ expect(model.getFieldIndex('grid', 0)).toBe(1);
129
+ expect(model.getFieldIndex('grid', 1)).toBe(2);
130
+ });
131
+
132
+ it('mutates the pinned field via its frozen-plane column', () => {
133
+ model.updateCellData({ plane: 'frozenColsStart', col: 0, row: 0 }, () => 'Pinned');
134
+ const cells = presentation.getCells({ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }, 'frozenColsStart');
135
+ expect(cells['0,0']?.value).toBe('Pinned');
136
+ });
137
+
138
+ it('ignores mutations for out-of-range columns without throwing', () => {
139
+ expect(() => model.updateCellData({ plane: 'grid', col: 999, row: 0 }, () => 'x')).not.toThrow();
140
+ // The pinned field is unchanged.
141
+ const cells = presentation.getCells({ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }, 'frozenColsStart');
142
+ expect(cells['0,0']?.value).toBe('A');
143
+ });
144
+ });
87
145
  });
88
146
 
89
147
  const Test = Type.makeObject(DXN.make('com.example.type.test', '0.1.0'))(