@dxos/react-ui-table 0.8.3 → 0.8.4-main.1068cf700f

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 (171) hide show
  1. package/dist/lib/browser/chunk-GTGYBRJA.mjs +1748 -0
  2. package/dist/lib/browser/chunk-GTGYBRJA.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-OZBLY7XM.mjs +76 -0
  4. package/dist/lib/browser/chunk-OZBLY7XM.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +1156 -2508
  6. package/dist/lib/browser/index.mjs.map +4 -4
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +114 -0
  9. package/dist/lib/browser/testing/index.mjs.map +7 -0
  10. package/dist/lib/browser/types/index.mjs +3 -5
  11. package/dist/lib/node-esm/chunk-DPNSZHU5.mjs +1750 -0
  12. package/dist/lib/node-esm/chunk-DPNSZHU5.mjs.map +7 -0
  13. package/dist/lib/node-esm/chunk-WNZSKIRF.mjs +77 -0
  14. package/dist/lib/node-esm/chunk-WNZSKIRF.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +1156 -2508
  16. package/dist/lib/node-esm/index.mjs.map +4 -4
  17. package/dist/lib/node-esm/meta.json +1 -1
  18. package/dist/lib/node-esm/testing/index.mjs +115 -0
  19. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  20. package/dist/lib/node-esm/types/index.mjs +3 -5
  21. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts +2 -3
  22. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/ColumnSettings.d.ts +4 -2
  24. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/DynamicTable.d.ts +7 -7
  26. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +37 -3
  28. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Table/Relations.stories.d.ts +111 -0
  30. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Table/RowActionsMenu.d.ts +1 -1
  32. package/dist/types/src/components/Table/RowActionsMenu.d.ts.map +1 -1
  33. package/dist/types/src/components/Table/Table.d.ts +13 -9
  34. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  35. package/dist/types/src/components/Table/Table.stories.d.ts +108 -4
  36. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  37. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +2 -1
  38. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -1
  39. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +14 -9
  40. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  41. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +12 -18
  42. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  43. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +39 -4
  44. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/TableCellEditor/index.d.ts +1 -0
  46. package/dist/types/src/components/TableCellEditor/index.d.ts.map +1 -1
  47. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts +4 -4
  48. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts.map +1 -1
  49. package/dist/types/src/components/testing.d.ts +26 -56
  50. package/dist/types/src/components/testing.d.ts.map +1 -1
  51. package/dist/types/src/hooks/index.d.ts +2 -0
  52. package/dist/types/src/hooks/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/useAddRow.d.ts +13 -0
  54. package/dist/types/src/hooks/useAddRow.d.ts.map +1 -0
  55. package/dist/types/src/hooks/useProjectionModel.d.ts +6 -0
  56. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  57. package/dist/types/src/hooks/useTableModel.d.ts +11 -10
  58. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  59. package/dist/types/src/index.d.ts +1 -2
  60. package/dist/types/src/index.d.ts.map +1 -1
  61. package/dist/types/src/model/modal-controller.d.ts +5 -8
  62. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  63. package/dist/types/src/model/selection-model.d.ts +17 -7
  64. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  65. package/dist/types/src/model/table-model.d.ts +149 -32
  66. package/dist/types/src/model/table-model.d.ts.map +1 -1
  67. package/dist/types/src/model/table-presentation.d.ts +13 -2
  68. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  69. package/dist/types/src/playwright/TableManager.d.ts +2 -1
  70. package/dist/types/src/playwright/TableManager.d.ts.map +1 -1
  71. package/dist/types/src/playwright/playwright.config.d.ts +3 -0
  72. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -0
  73. package/dist/types/src/testing/index.d.ts +2 -0
  74. package/dist/types/src/testing/index.d.ts.map +1 -0
  75. package/dist/types/src/testing/useTestTableModel.d.ts +25 -0
  76. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -0
  77. package/dist/types/src/translations.d.ts +5 -6
  78. package/dist/types/src/translations.d.ts.map +1 -1
  79. package/dist/types/src/types/Table.d.ts +31 -0
  80. package/dist/types/src/types/Table.d.ts.map +1 -0
  81. package/dist/types/src/types/index.d.ts +1 -1
  82. package/dist/types/src/types/index.d.ts.map +1 -1
  83. package/dist/types/src/util/dynamic-table.d.ts +15 -14
  84. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  85. package/dist/types/src/util/dynamic-table.test.d.ts +2 -0
  86. package/dist/types/src/util/dynamic-table.test.d.ts.map +1 -0
  87. package/dist/types/src/util/index.d.ts +2 -1
  88. package/dist/types/src/util/index.d.ts.map +1 -1
  89. package/dist/types/src/util/query-ast.d.ts +7 -0
  90. package/dist/types/src/util/query-ast.d.ts.map +1 -0
  91. package/dist/types/src/util/schema.d.ts +1 -1
  92. package/dist/types/src/util/schema.d.ts.map +1 -1
  93. package/dist/types/src/util/sort.d.ts +12 -0
  94. package/dist/types/src/util/sort.d.ts.map +1 -0
  95. package/dist/types/src/util/table-buttons.d.ts +15 -1
  96. package/dist/types/src/util/table-buttons.d.ts.map +1 -1
  97. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  98. package/dist/types/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +69 -56
  100. package/src/components/Table/ColumnActionsMenu.tsx +11 -7
  101. package/src/components/Table/ColumnSettings.tsx +16 -12
  102. package/src/components/Table/DynamicTable.stories.tsx +29 -25
  103. package/src/components/Table/DynamicTable.tsx +34 -22
  104. package/src/components/Table/Relations.stories.tsx +237 -0
  105. package/src/components/Table/RowActionsMenu.tsx +26 -17
  106. package/src/components/Table/Table.stories.tsx +171 -194
  107. package/src/components/Table/Table.tsx +364 -286
  108. package/src/components/TableCellEditor/CellValidationMessage.tsx +8 -3
  109. package/src/components/TableCellEditor/FormCellEditor.tsx +146 -49
  110. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +36 -76
  111. package/src/components/TableCellEditor/TableCellEditor.tsx +106 -198
  112. package/src/components/TableCellEditor/index.ts +1 -0
  113. package/src/components/TableToolbar/TableToolbar.tsx +49 -33
  114. package/src/components/testing.ts +41 -32
  115. package/src/hooks/index.ts +2 -0
  116. package/src/hooks/useAddRow.ts +36 -0
  117. package/src/hooks/useProjectionModel.ts +39 -0
  118. package/src/hooks/useTableModel.ts +41 -35
  119. package/src/index.ts +1 -2
  120. package/src/model/modal-controller.ts +24 -41
  121. package/src/model/selection-model.ts +75 -41
  122. package/src/model/table-model.test.ts +92 -56
  123. package/src/model/table-model.ts +628 -162
  124. package/src/model/table-presentation.test.ts +70 -59
  125. package/src/model/table-presentation.ts +278 -149
  126. package/src/playwright/TableManager.ts +13 -6
  127. package/src/playwright/playwright.config.ts +17 -0
  128. package/src/playwright/smoke.spec.ts +180 -41
  129. package/src/testing/index.ts +5 -0
  130. package/src/testing/useTestTableModel.ts +133 -0
  131. package/src/translations.ts +7 -5
  132. package/src/types/Table.ts +90 -0
  133. package/src/types/index.ts +2 -2
  134. package/src/util/dynamic-table.test.ts +70 -0
  135. package/src/util/dynamic-table.ts +50 -36
  136. package/src/util/index.ts +2 -1
  137. package/src/util/query-ast.ts +19 -0
  138. package/src/util/schema.ts +4 -4
  139. package/src/util/sort.ts +90 -0
  140. package/src/util/table-buttons.ts +28 -4
  141. package/src/util/table-controls.ts +4 -1
  142. package/dist/lib/browser/chunk-TL7CKQQV.mjs +0 -27
  143. package/dist/lib/browser/chunk-TL7CKQQV.mjs.map +0 -7
  144. package/dist/lib/node/chunk-O2VPBNS5.cjs +0 -50
  145. package/dist/lib/node/chunk-O2VPBNS5.cjs.map +0 -7
  146. package/dist/lib/node/index.cjs +0 -2739
  147. package/dist/lib/node/index.cjs.map +0 -7
  148. package/dist/lib/node/meta.json +0 -1
  149. package/dist/lib/node/types/index.cjs +0 -31
  150. package/dist/lib/node/types/index.cjs.map +0 -7
  151. package/dist/lib/node-esm/chunk-GBT6GD4G.mjs +0 -29
  152. package/dist/lib/node-esm/chunk-GBT6GD4G.mjs.map +0 -7
  153. package/dist/types/src/components/Table/CreateRefPanel.d.ts +0 -9
  154. package/dist/types/src/components/Table/CreateRefPanel.d.ts.map +0 -1
  155. package/dist/types/src/components/Table/relations.stories.d.ts +0 -9
  156. package/dist/types/src/components/Table/relations.stories.d.ts.map +0 -1
  157. package/dist/types/src/components/TableCellEditor/extension.d.ts +0 -13
  158. package/dist/types/src/components/TableCellEditor/extension.d.ts.map +0 -1
  159. package/dist/types/src/model/table-sorting.d.ts +0 -53
  160. package/dist/types/src/model/table-sorting.d.ts.map +0 -1
  161. package/dist/types/src/types/table.d.ts +0 -56
  162. package/dist/types/src/types/table.d.ts.map +0 -1
  163. package/dist/types/src/util/table.d.ts +0 -14
  164. package/dist/types/src/util/table.d.ts.map +0 -1
  165. package/src/components/Table/CreateRefPanel.tsx +0 -84
  166. package/src/components/Table/relations.stories.tsx +0 -124
  167. package/src/components/TableCellEditor/extension.ts +0 -61
  168. package/src/model/table-sorting.ts +0 -216
  169. package/src/playwright/playwright.config.cts +0 -18
  170. package/src/types/table.ts +0 -27
  171. package/src/util/table.ts +0 -127
@@ -2,44 +2,93 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { computed, effect, signal, type ReadonlySignal } from '@preact/signals-core';
5
+ import { Atom, type Registry } from '@effect-atom/atom-react';
6
6
 
7
- import { type Space } from '@dxos/client/echo';
8
7
  import { Resource } from '@dxos/context';
9
- import {
10
- type FieldSortType,
11
- FormatEnum,
12
- getValue,
13
- setValue,
14
- type JsonProp,
15
- getSnapshot,
16
- getSchema,
17
- } from '@dxos/echo-schema';
8
+ import { type Database, Format, Obj, Order, Query, type QueryAST, Ref } from '@dxos/echo';
9
+ import { type JsonProp, type JsonSchemaType, type Mutable, toEffectSchema } from '@dxos/echo/internal';
10
+ import { getSnapshot } from '@dxos/echo/internal';
11
+ import { getValue, setValue } from '@dxos/effect';
18
12
  import { invariant } from '@dxos/invariant';
19
- import { isLiveObject, makeRef } from '@dxos/live-object';
13
+ import { ObjectId } from '@dxos/keys';
14
+ import { type Label } from '@dxos/react-ui';
20
15
  import { formatForEditing, parseValue } from '@dxos/react-ui-form';
21
16
  import {
22
17
  type DxGridAxisMeta,
23
- type DxGridPlaneRange,
24
18
  type DxGridPlanePosition,
19
+ type DxGridPlaneRange,
25
20
  type DxGridPosition,
26
21
  } from '@dxos/react-ui-grid';
27
- import { type ViewType, type ViewProjection, type PropertyType, validateSchema } from '@dxos/schema';
22
+ import { type ProjectionModel, type PropertyType, type ValidationError, type View, validateSchema } from '@dxos/schema';
28
23
 
29
- import { type SelectionMode, SelectionModel } from './selection-model';
30
- import { TableSorting } from './table-sorting';
31
- import { touch } from '../util';
24
+ import { type Table } from '../types';
25
+ import { extractOrder } from '../util';
26
+ import { compareValues } from '../util/sort';
32
27
  import { extractTagIds } from '../util/tag';
33
28
 
29
+ /**
30
+ * Field sort configuration.
31
+ */
32
+ export type FieldSortType = {
33
+ fieldId: string;
34
+ direction: QueryAST.OrderDirection;
35
+ };
36
+
37
+ import { type SelectionMode, SelectionModel } from './selection-model';
38
+
39
+ /**
40
+ * Callback type for wrapping mutations in Obj.change().
41
+ * Contains separate callbacks for table object and row mutations.
42
+ */
43
+ export type TableChangeCallback<T extends TableRow> = {
44
+ /** Callback to wrap table object mutations. */
45
+ table: (mutate: (mutableTable: Table.Table) => void) => void;
46
+ /** Callback to wrap row mutations. */
47
+ row: (row: T, mutate: (mutableRow: T) => void) => void;
48
+ };
49
+
50
+ /**
51
+ * Creates a change callback for ECHO-backed table and rows.
52
+ * Use this when the table and rows are stored in the ECHO database.
53
+ */
54
+ export const createEchoChangeCallback = <T extends TableRow>(table: Table.Table): TableChangeCallback<T> => ({
55
+ table: (mutate) => Obj.change(table, (mutableTable) => mutate(mutableTable as unknown as Table.Table)),
56
+ row: (row, mutate) => {
57
+ if (Obj.isObject(row)) {
58
+ Obj.change(row, (mutableRow) => mutate(mutableRow as T));
59
+ } else {
60
+ mutate(row);
61
+ }
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Creates a change callback that directly mutates objects without wrapping.
67
+ * Use this for plain JavaScript objects (tests, non-ECHO scenarios).
68
+ */
69
+ export const createDirectChangeCallback = <T extends TableRow>(table: Table.Table): TableChangeCallback<T> => ({
70
+ table: (mutate) => mutate(table),
71
+ row: (row, mutate) => mutate(row),
72
+ });
73
+
74
+ // Domain types for cell classification
75
+ export type TableCellType = 'standard' | 'draft' | 'header';
76
+
34
77
  // TODO(ZaymonFC): Use a common type?
35
78
  export type ValidationResult = { valid: true } | { valid: false; error: string };
36
79
 
80
+ export type DraftRow<T extends TableRow = TableRow> = {
81
+ data: T;
82
+ valid: boolean;
83
+ validationErrors: ValidationError[];
84
+ };
85
+
37
86
  // TODO(burdon): Use schema types.
38
87
  export type TableRow = Record<JsonProp, any> & { id: string };
39
88
 
40
89
  export type TableRowAction = {
41
90
  id: string;
42
- translationKey: string;
91
+ label: Label;
43
92
  };
44
93
 
45
94
  export type TableFeatures = {
@@ -54,71 +103,92 @@ const defaultFeatures: TableFeatures = {
54
103
  schemaEditable: false,
55
104
  };
56
105
 
106
+ export type InsertRowResult = 'draft' | 'final';
107
+
57
108
  export type TableModelProps<T extends TableRow = TableRow> = {
58
- id?: string;
59
- space?: Space;
60
- view?: ViewType;
61
- projection: ViewProjection;
109
+ registry: Registry.Registry;
110
+ object: Table.Table;
111
+ projection: ProjectionModel;
112
+ db?: Database.Database;
113
+ /**
114
+ * Callbacks to wrap mutations in Obj.change().
115
+ * Use createEchoChangeCallback() for ECHO-backed objects or createDirectChangeCallback() for plain objects.
116
+ */
117
+ change?: TableChangeCallback<T>;
62
118
  features?: Partial<TableFeatures>;
63
- sorting?: FieldSortType[];
119
+ initialSelection?: string[];
64
120
  pinnedRows?: { top: number[]; bottom: number[] };
65
121
  rowActions?: TableRowAction[];
66
- onInsertRow?: (index?: number) => void;
122
+ onResolveSchema?: (typename: string) => Promise<JsonSchemaType>;
123
+ onInsertRow?: (data?: any) => InsertRowResult;
67
124
  onDeleteRows?: (index: number, obj: T[]) => void;
68
- onDeleteColumn?: (fieldId: string) => void;
125
+ onColumnDelete?: (fieldId: string) => void;
69
126
  onCellUpdate?: (cell: DxGridPosition) => void;
70
127
  onRowOrderChange?: () => void;
71
128
  onRowAction?: (actionId: string, data: T) => void;
72
129
  };
73
130
 
74
131
  export class TableModel<T extends TableRow = TableRow> extends Resource {
75
- private readonly _id: string | undefined;
76
- private readonly _space: Space | undefined;
77
- private readonly _view: ViewType | undefined;
78
- private readonly _projection: ViewProjection;
132
+ private readonly _registry: Registry.Registry;
133
+ private readonly _object: Table.Table;
134
+ private readonly _projection: ProjectionModel;
135
+ private readonly _db?: Database.Database;
136
+ private readonly _change: TableChangeCallback<T>;
79
137
 
80
- private readonly _visibleRange = signal<DxGridPlaneRange>({
81
- start: { row: 0, col: 0 },
82
- end: { row: 0, col: 0 },
83
- });
138
+ private readonly _visibleRange: Atom.Writable<DxGridPlaneRange>;
84
139
 
85
- private readonly _onInsertRow?: TableModelProps<T>['onInsertRow'];
140
+ private readonly _onInsertRow?: (data?: any) => InsertRowResult;
86
141
  private readonly _onDeleteRows?: TableModelProps<T>['onDeleteRows'];
87
- private readonly _onDeleteColumn?: TableModelProps<T>['onDeleteColumn'];
142
+ private readonly _onColumnDelete?: TableModelProps<T>['onColumnDelete'];
88
143
  private readonly _onCellUpdate?: TableModelProps<T>['onCellUpdate'];
89
144
  private readonly _onRowOrderChange?: TableModelProps<T>['onRowOrderChange'];
90
145
  private readonly _onRowAction?: TableModelProps<T>['onRowAction'];
91
146
  private readonly _rowActions: TableRowAction[];
92
147
  private readonly _features: TableFeatures;
93
148
 
94
- private readonly _rows = signal<T[]>([]);
95
- private readonly _sorting: TableSorting<T>;
149
+ private readonly _rows: Atom.Writable<T[]>;
150
+ private readonly _draftRows: Atom.Writable<DraftRow<T>[]>;
151
+ // In-memory sort state - changes are local until saved.
152
+ private readonly _inMemorySort: Atom.Writable<FieldSortType | undefined>;
153
+ // Derived atom for persisted sort from view.query.ast.
154
+ private readonly _persistedSort: Atom.Atom<FieldSortType | undefined>;
155
+ // Derived atom for current sort (in-memory takes precedence over persisted).
156
+ private readonly _sorting: Atom.Atom<FieldSortType | undefined>;
157
+ // Derived atom for sorted rows (used by rows getter and SelectionModel).
158
+ private readonly _sortedRows: Atom.Atom<T[]>;
159
+ // Derived atom for viewDirty state.
160
+ private readonly _viewDirty: Atom.Atom<boolean>;
96
161
 
97
162
  private _pinnedRows: NonNullable<TableModelProps<T>['pinnedRows']>;
98
163
  private _selection!: SelectionModel<T>;
99
- private _columnMeta?: ReadonlySignal<DxGridAxisMeta>;
164
+ private _columnMeta?: Atom.Atom<DxGridAxisMeta>;
165
+ // Counter atom that increments when cells are updated - allows UI to react to cell changes.
166
+ private readonly _cellUpdateCounter: Atom.Writable<number>;
100
167
 
101
168
  constructor({
102
- id,
103
- space,
104
- view,
169
+ registry,
170
+ object,
105
171
  projection,
172
+ db,
173
+ change,
106
174
  features = {},
107
- sorting = [],
175
+ initialSelection = [],
108
176
  pinnedRows = { top: [], bottom: [] },
109
177
  rowActions = [],
110
178
  onCellUpdate,
111
- onDeleteColumn,
179
+ onColumnDelete,
112
180
  onDeleteRows,
113
181
  onInsertRow,
114
182
  onRowOrderChange,
115
183
  onRowAction,
116
184
  }: TableModelProps<T>) {
117
185
  super();
118
- this._id = id;
119
- this._space = space;
120
- this._view = view;
186
+ this._registry = registry;
187
+ this._object = object;
121
188
  this._projection = projection;
189
+ this._projection.normalizeView();
190
+ this._db = db;
191
+ this._change = change ?? createEchoChangeCallback<T>(object);
122
192
 
123
193
  // TODO(ZaymonFC): Use our more robust config merging module?
124
194
  this._features = { ...defaultFeatures, ...features };
@@ -128,41 +198,154 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
128
198
  'Single selection is not compatible with editable tables.',
129
199
  );
130
200
 
131
- this._sorting = new TableSorting(this._rows, this._view, projection);
201
+ // Initialize writable atoms.
202
+ this._visibleRange = Atom.make<DxGridPlaneRange>({
203
+ start: { row: 0, col: 0 },
204
+ end: { row: 0, col: 0 },
205
+ });
206
+ this._rows = Atom.make<T[]>([]);
207
+ this._draftRows = Atom.make<DraftRow<T>[]>([]);
208
+ this._inMemorySort = Atom.make<FieldSortType | undefined>(undefined);
209
+ this._cellUpdateCounter = Atom.make<number>(0);
210
+
211
+ // Create derived atom for persisted sort from view.query.ast.
212
+ this._persistedSort = Atom.make((_get) => {
213
+ // Note: This depends on the view being loaded and the projection being set.
214
+ // The view is loaded in _open().
215
+ const viewSnapshot = Obj.getSnapshot(this.view);
216
+ const ast = viewSnapshot.query.ast;
217
+ const orders = extractOrder(ast);
218
+
219
+ if (orders && orders.length > 0) {
220
+ const firstOrder = orders[0];
221
+ if (firstOrder.kind === 'property') {
222
+ // Find field by property path.
223
+ const field = this._projection.getFields().find((f) => f.path === firstOrder.property);
224
+ if (field) {
225
+ return {
226
+ fieldId: field.id,
227
+ direction: firstOrder.direction,
228
+ };
229
+ }
230
+ }
231
+ }
232
+
233
+ return undefined;
234
+ });
235
+
236
+ // Create derived atom for current sort (in-memory takes precedence over persisted).
237
+ this._sorting = Atom.make((get) => {
238
+ const inMemorySort = get(this._inMemorySort);
239
+ return inMemorySort ?? get(this._persistedSort);
240
+ });
241
+
242
+ // Create derived atom for sorted rows (used by rows getter and SelectionModel).
243
+ this._sortedRows = Atom.make((get) => {
244
+ const rows = get(this._rows);
245
+ const sort = get(this._sorting);
246
+
247
+ if (!sort || rows.length === 0) {
248
+ return rows;
249
+ }
250
+
251
+ const field = this._projection.getFields().find((f) => f.id === sort.fieldId);
252
+ if (!field) {
253
+ return rows;
254
+ }
255
+
256
+ // Get field projection to check format.
257
+ const fieldProjection = this._projection.getFieldProjection(field.id);
258
+
259
+ const sorted = [...rows].sort((a, b) => {
260
+ const aValue = getValue(a, field.path);
261
+ const bValue = getValue(b, field.path);
262
+ return compareValues(aValue, bValue, fieldProjection.props.format, sort.direction);
263
+ });
264
+
265
+ return sorted;
266
+ });
267
+
268
+ this._selection = new SelectionModel(
269
+ this._registry,
270
+ this._sortedRows,
271
+ this._features.selection.mode ?? 'multiple',
272
+ initialSelection,
273
+ () => this._onRowOrderChange?.(),
274
+ );
275
+
276
+ // Create derived atom for viewDirty.
277
+ this._viewDirty = Atom.make((get) => {
278
+ const inMemorySort = get(this._inMemorySort);
279
+ if (!inMemorySort) {
280
+ return false;
281
+ }
132
282
 
133
- if (sorting.length > 0) {
134
- const [sort] = sorting;
135
- this._sorting.setSort(sort.fieldId, sort.direction);
136
- }
283
+ const persisted = get(this._persistedSort);
284
+ if (!persisted) {
285
+ return true; // In-memory sort but no persisted sort.
286
+ }
287
+
288
+ return inMemorySort.fieldId !== persisted.fieldId || inMemorySort.direction !== persisted.direction;
289
+ });
137
290
 
138
291
  this._pinnedRows = pinnedRows;
139
292
  this._rowActions = rowActions;
140
293
  this._onInsertRow = onInsertRow;
141
294
  this._onDeleteRows = onDeleteRows;
142
- this._onDeleteColumn = onDeleteColumn;
295
+ this._onColumnDelete = onColumnDelete;
143
296
  this._onCellUpdate = onCellUpdate;
144
297
  this._onRowOrderChange = onRowOrderChange;
145
298
  this._onRowAction = onRowAction;
146
299
  }
147
300
 
148
- public get id() {
149
- return this._id;
301
+ public get id(): string {
302
+ return Obj.getDXN(this._object).toString();
150
303
  }
151
304
 
152
- public get space() {
153
- return this._space;
305
+ public get view(): View.View {
306
+ const view = this._object.view.target;
307
+ invariant(view, 'Table model not initialized');
308
+ return view;
154
309
  }
155
310
 
156
- public get view() {
157
- return this._view;
311
+ public get table(): Table.Table {
312
+ return this._object;
158
313
  }
159
314
 
160
- public get projection() {
315
+ public get projection(): ProjectionModel {
161
316
  return this._projection;
162
317
  }
163
318
 
164
- public get rows(): ReadonlySignal<T[]> {
165
- return this._sorting.sortedRows;
319
+ public get db(): Database.Database | undefined {
320
+ return this._db;
321
+ }
322
+
323
+ /**
324
+ * Atom for reactive access to rows with in-memory sorting applied.
325
+ */
326
+ public get rows(): Atom.Atom<T[]> {
327
+ return this._sortedRows;
328
+ }
329
+
330
+ /**
331
+ * Gets rows with in-memory sorting applied.
332
+ * In-memory sort is local to this instance until saved.
333
+ */
334
+ public getRows(): T[] {
335
+ return this._registry.get(this._sortedRows);
336
+ }
337
+
338
+ /** Atom for reactive access to cell changes. */
339
+ public get cellUpdate(): Atom.Atom<number> {
340
+ return this._cellUpdateCounter;
341
+ }
342
+
343
+ /**
344
+ * Change a row using the configured change callback.
345
+ * Use this instead of directly mutating to ensure consistent mutation handling.
346
+ */
347
+ public changeRow(row: T, mutate: (mutableRow: T) => void): void {
348
+ this._change.row(row, mutate);
166
349
  }
167
350
 
168
351
  public get features(): TableFeatures {
@@ -170,28 +353,30 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
170
353
  }
171
354
 
172
355
  /**
173
- * @reactive
356
+ * Gets the current sort state.
357
+ * Priority: in-memory sort (local changes) > persisted sort (from query AST).
174
358
  */
175
- public get sorting(): TableSorting<T> | undefined {
176
- return this._sorting;
359
+ public getSorting(): FieldSortType | undefined {
360
+ return this._registry.get(this._sorting);
177
361
  }
178
362
 
179
363
  /**
180
364
  * Gets the row data at the specified display index.
181
365
  */
182
366
  public getRowAt(displayIndex: number): T | undefined {
183
- if (displayIndex < 0 || displayIndex >= this._sorting.sortedRows.value.length) {
367
+ const sortedRows = this._registry.get(this._sortedRows);
368
+ if (displayIndex < 0 || displayIndex >= sortedRows.length) {
184
369
  return undefined;
185
370
  }
186
371
 
187
- return this._sorting.sortedRows.value[displayIndex];
372
+ return sortedRows[displayIndex];
188
373
  }
189
374
 
190
375
  public get pinnedRows(): NonNullable<TableModelProps<T>['pinnedRows']> {
191
376
  return this._pinnedRows;
192
377
  }
193
378
 
194
- public get columnMeta(): ReadonlySignal<DxGridAxisMeta> {
379
+ public get columnMeta(): Atom.Atom<DxGridAxisMeta> {
195
380
  invariant(this._columnMeta);
196
381
  return this._columnMeta;
197
382
  }
@@ -200,11 +385,6 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
200
385
  return this._selection;
201
386
  }
202
387
 
203
- /** @reactive */
204
- public get isViewDirty(): boolean {
205
- return this._sorting.isDirty;
206
- }
207
-
208
388
  public get rowActions(): TableRowAction[] {
209
389
  return this._rowActions;
210
390
  }
@@ -214,57 +394,77 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
214
394
  //
215
395
 
216
396
  protected override async _open(): Promise<void> {
397
+ await this._object.view.load();
217
398
  this.initializeColumnMeta();
218
399
  this.initializeEffects();
219
- this._selection = new SelectionModel(this._sorting.sortedRows, this._features.selection.mode ?? 'multiple', () =>
220
- this._onRowOrderChange?.(),
221
- );
222
400
  await this._selection.open(this._ctx);
223
401
  }
224
402
 
225
403
  private initializeColumnMeta(): void {
226
- this._columnMeta = computed(() => {
227
- const fields = this._view?.fields ?? [];
404
+ invariant(this._object.view.target, 'View must be loaded before initializing column meta');
405
+
406
+ // Derive columnMeta from the projection's fields atom - updates when fields change.
407
+ // The projection.fields atom handles subscriptions to view changes internally.
408
+ this._columnMeta = Atom.make((get) => {
409
+ const fields = get(this._projection.fields);
228
410
  const meta = Object.fromEntries(
229
- fields.map((field, index: number) => [index, { size: field?.size ?? 256, resizeable: true }]),
411
+ fields.map((field, index: number) => [index, { size: this.table.sizes[field.path] ?? 256, resizeable: true }]),
230
412
  );
231
413
 
232
414
  return {
233
415
  grid: meta,
234
416
  frozenColsStart: { 0: { size: 30, resizeable: false } },
235
- frozenColsEnd: { 0: { size: 40, resizeable: false } },
417
+ frozenColsEnd: { 0: { size: 32, resizeable: false } },
236
418
  };
237
419
  });
238
420
  }
239
421
 
240
422
  private initializeEffects(): void {
241
- const rowOrderWatcher = effect(() => {
242
- touch(this._sorting.sortedRows.value);
423
+ // Subscribe to rows changes to notify row order changed.
424
+ const rowsUnsubscribe = this._registry.subscribe(this._rows, () => {
243
425
  this._onRowOrderChange?.();
244
426
  });
245
- this._ctx.onDispose(rowOrderWatcher);
246
-
247
- /**
248
- * Creates reactive effects for each row in the visible range.
249
- * Subscribes to changes in the visible range, recreating row effects when it changes.
250
- * When a row's data changes, invokes onCellUpdate to notify subscribers and trigger UI updates.
251
- */
252
- const rowEffectManager = effect(() => {
253
- const { start, end } = touch(this._visibleRange.value);
254
- const rowEffects: ReturnType<typeof effect>[] = [];
255
- for (let row = start.row; row <= end.row; row++) {
256
- rowEffects.push(
257
- effect(() => {
258
- const obj = this._sorting.sortedRows.value[row];
259
- this._view?.fields.forEach((field) => touch(getValue(obj, field.path)));
427
+ this._ctx.onDispose(rowsUnsubscribe);
428
+
429
+ // Track row subscriptions for cleanup.
430
+ const rowSubscriptions = new Map<string, () => void>();
431
+
432
+ // Subscribe to visible range changes to set up row subscriptions.
433
+ const visibleRangeUnsubscribe = this._registry.subscribe(this._visibleRange, () => {
434
+ // Unsubscribe from old row subscriptions.
435
+ for (const [id, unsub] of rowSubscriptions) {
436
+ unsub();
437
+ rowSubscriptions.delete(id);
438
+ }
439
+
440
+ const { start, end } = this._registry.get(this._visibleRange);
441
+ const sortedRows = this._registry.get(this._sortedRows);
442
+
443
+ // Subscribe to each visible row's changes.
444
+ for (let row = start.row; row <= end.row && row < sortedRows.length; row++) {
445
+ const obj = sortedRows[row];
446
+ if (Obj.isObject(obj) && !rowSubscriptions.has(obj.id)) {
447
+ const unsub = Obj.subscribe(obj, () => {
448
+ // Increment cell update counter to notify UI of changes.
449
+ this._registry.set(this._cellUpdateCounter, this._registry.get(this._cellUpdateCounter) + 1);
260
450
  this._onCellUpdate?.({ row, col: start.col, plane: 'grid' });
261
- }),
262
- );
451
+ });
452
+ rowSubscriptions.set(obj.id, unsub);
453
+ }
454
+ }
455
+ });
456
+ this._ctx.onDispose(() => {
457
+ visibleRangeUnsubscribe();
458
+ for (const unsub of rowSubscriptions.values()) {
459
+ unsub();
263
460
  }
461
+ });
264
462
 
265
- return () => rowEffects.forEach((cleanup) => cleanup());
463
+ // Subscribe to draft rows changes to notify grid.
464
+ const draftRowsUnsubscribe = this._registry.subscribe(this._draftRows, () => {
465
+ this._onRowOrderChange?.();
266
466
  });
267
- this._ctx.onDispose(rowEffectManager);
467
+ this._ctx.onDispose(draftRowsUnsubscribe);
268
468
  }
269
469
 
270
470
  //
@@ -272,31 +472,128 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
272
472
  //
273
473
 
274
474
  public setRows = (obj: T[]): void => {
275
- this._rows.value = obj;
475
+ this._registry.set(this._rows, obj);
276
476
  };
277
477
 
278
- public getRowCount = (): number => this._rows.value.length;
478
+ public getRowCount = (): number => this._registry.get(this._sortedRows).length;
479
+
480
+ public getDraftRowCount(): number {
481
+ return this._registry.get(this._draftRows).length;
482
+ }
483
+
484
+ /** Atom for reactive access to draft rows. */
485
+ public get draftRows(): Atom.Atom<DraftRow<T>[]> {
486
+ return this._draftRows;
487
+ }
488
+
489
+ /** Gets the current draft rows. */
490
+ public getDraftRows(): DraftRow<T>[] {
491
+ return this._registry.get(this._draftRows);
492
+ }
493
+
494
+ /**
495
+ * Checks if a specific field path has validation errors for a given draft row.
496
+ * @param draftRowIndex - The index of the draft row to check
497
+ * @param fieldPath - The path of the field to check for validation errors
498
+ * @returns true if the field has validation errors, false otherwise
499
+ */
500
+ public hasDraftRowValidationError(draftRowIndex: number, fieldPath: string): boolean {
501
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
502
+ return false;
503
+ }
504
+
505
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
506
+ const validationErrors = draftRow.validationErrors || [];
507
+ return validationErrors.some((error) => error.path === fieldPath);
508
+ }
509
+
510
+ public getColumnCount = (): number => this._projection?.getFields().length ?? 0;
511
+
512
+ public insertRow = (): InsertRowResult => {
513
+ const result = this._onInsertRow?.();
514
+ if (result === 'draft' && this._registry.get(this._draftRows).length === 0) {
515
+ this.createDraftRow();
516
+ }
517
+ return result ?? 'final';
518
+ };
519
+
520
+ private createDraftRow(): void {
521
+ if (!this._projection) {
522
+ return;
523
+ }
524
+
525
+ // NOTE(ZaymonFC): This is initialized with id because it's required in all schemata?
526
+ const draftData = { id: ObjectId.random() } as any as T;
527
+ const validationErrors = this.validateDraftRowData(draftData);
528
+
529
+ const draftRow: DraftRow<T> = {
530
+ data: draftData,
531
+ valid: validationErrors.length === 0,
532
+ validationErrors,
533
+ };
534
+
535
+ this._registry.set(this._draftRows, [...this._registry.get(this._draftRows), draftRow]);
536
+ }
537
+
538
+ private validateDraftRow(draftRowIndex: number): void {
539
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
540
+ return;
541
+ }
542
+
543
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
544
+ const validationErrors = this.validateDraftRowData(draftRow.data);
279
545
 
280
- public getColumnCount = (): number => this._view?.fields.length ?? 0;
546
+ const updatedDraftRow = {
547
+ ...draftRow,
548
+ valid: validationErrors.length === 0,
549
+ validationErrors,
550
+ };
281
551
 
282
- public insertRow = (rowIndex?: number): void => {
283
- const row = rowIndex !== undefined ? this._sorting.getDataIndex(rowIndex) : this._rows.value.length;
284
- this._onInsertRow?.(row);
552
+ const newDraftRows = [...this._registry.get(this._draftRows)];
553
+ newDraftRows[draftRowIndex] = updatedDraftRow;
554
+ this._registry.set(this._draftRows, newDraftRows);
555
+ }
556
+
557
+ private validateDraftRowData(data: T): ValidationError[] {
558
+ const schema = toEffectSchema(this._projection.baseSchema);
559
+ return validateSchema(schema, data) || [];
560
+ }
561
+
562
+ public commitDraftRow = (draftRowIndex: number): boolean => {
563
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
564
+ return false;
565
+ }
566
+
567
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
568
+ if (!draftRow.valid) {
569
+ return false;
570
+ }
571
+
572
+ const insertRowResult = this._onInsertRow?.(draftRow.data);
573
+
574
+ if (insertRowResult === 'final') {
575
+ const newDraftRows = this._registry.get(this._draftRows).filter((_, index) => index !== draftRowIndex);
576
+ this._registry.set(this._draftRows, newDraftRows);
577
+ }
578
+
579
+ return insertRowResult === 'final';
285
580
  };
286
581
 
287
582
  public deleteRow = (rowIndex: number): void => {
288
- const row = this._sorting.getDataIndex(rowIndex);
289
- const obj = this._rows.value[row];
583
+ const sortedRows = this._registry.get(this._sortedRows);
584
+ const obj = sortedRows[rowIndex];
290
585
  const objectsToDelete = [];
291
586
 
292
- if (this.features.selection.enabled && this._selection.hasSelection.value) {
587
+ if (this.features.selection.enabled && this._selection.hasSelection) {
293
588
  const selectedRows = this._selection.getSelectedRows();
294
589
  objectsToDelete.push(...selectedRows);
295
- } else {
590
+ } else if (obj) {
296
591
  objectsToDelete.push(obj);
297
592
  }
298
593
 
299
- this._onDeleteRows?.(row, objectsToDelete);
594
+ if (objectsToDelete.length > 0) {
595
+ this._onDeleteRows?.(rowIndex, objectsToDelete);
596
+ }
300
597
  };
301
598
 
302
599
  public handleRowAction = (actionId: string, rowIndex: number): void => {
@@ -304,30 +601,44 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
304
601
  return;
305
602
  }
306
603
 
307
- const row = this._sorting.getDataIndex(rowIndex);
308
- const data = this._rows.value[row];
604
+ const sortedRows = this._registry.get(this._sortedRows);
605
+ const data = sortedRows[rowIndex];
309
606
 
310
607
  if (data) {
311
608
  this._onRowAction(actionId, data);
312
609
  }
313
610
  };
314
611
 
315
- public getCellData = ({ col, row }: DxGridPlanePosition): any => {
316
- const fields = this._view?.fields ?? [];
612
+ public getCellData = (cell: DxGridPosition): any => {
613
+ const { col, row, plane } = cell;
614
+ const fields = this._projection?.getFields() ?? [];
317
615
  if (col < 0 || col >= fields.length) {
318
616
  return undefined;
319
617
  }
320
618
 
321
619
  const field = fields[col];
322
- const dataIndex = this._sorting.getDataIndex(row);
323
- const value = getValue(this._rows.value[dataIndex], field.path);
620
+ let value: any;
621
+
622
+ if (plane === 'frozenRowsEnd') {
623
+ // Get data from draft row
624
+ if (row >= 0 && row < this._registry.get(this._draftRows).length) {
625
+ value = getValue(this._registry.get(this._draftRows)[row].data, field.path);
626
+ } else {
627
+ return undefined;
628
+ }
629
+ } else {
630
+ // Get data from regular row (use sorted rows for display index)
631
+ const sortedRows = this._registry.get(this._sortedRows);
632
+ value = getValue(sortedRows[row], field.path);
633
+ }
634
+
324
635
  if (value == null) {
325
636
  return '';
326
637
  }
327
638
 
328
639
  const { props } = this._projection.getFieldProjection(field.id);
329
640
  switch (props.format) {
330
- case FormatEnum.Ref: {
641
+ case Format.TypeFormat.Ref: {
331
642
  if (!field.referencePath) {
332
643
  return ''; // TODO(burdon): Show error.
333
644
  }
@@ -336,47 +647,70 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
336
647
  }
337
648
 
338
649
  default: {
339
- return formatForEditing({ type: props.type, format: props.format, value });
650
+ return formatForEditing({
651
+ type: props.type,
652
+ format: props.format,
653
+ value,
654
+ });
340
655
  }
341
656
  }
342
657
  };
343
658
 
344
- public validateCellData = async ({ col, row }: DxGridPlanePosition, value: any): Promise<ValidationResult> => {
345
- const rowIdx = this._sorting.getDataIndex(row);
346
- const fields = this._view?.fields ?? [];
659
+ public validateCellData = async ({ col, row, plane }: DxGridPosition, value: any): Promise<ValidationResult> => {
660
+ const fields = this._projection?.getFields() ?? [];
347
661
  if (col < 0 || col >= fields.length) {
348
662
  return { valid: false, error: 'Invalid column index' };
349
663
  }
350
664
 
351
665
  const field = fields[col];
352
666
  const { props } = this._projection.getFieldProjection(field.id);
667
+ const transformedValue = editorTextToCellValue(props, value);
353
668
 
354
- const currentRow = this._rows.value[rowIdx];
355
- invariant(currentRow, 'Invalid row index');
669
+ const isDraftRow = plane === 'frozenRowsEnd';
356
670
 
357
- // Get a snapshot of the current object.
358
- const snapshot = getSnapshot(currentRow);
359
- const transformedValue = editorTextToCellValue(props, value);
671
+ if (isDraftRow) {
672
+ const isInvalidDraftRowIndex = row < 0 || row >= this._registry.get(this._draftRows).length;
673
+ if (isInvalidDraftRowIndex) {
674
+ return { valid: false, error: 'Invalid draft row index' };
675
+ }
360
676
 
361
- // Set the proposed value.
362
- setValue(snapshot, field.path, transformedValue);
677
+ const draftRow = this._registry.get(this._draftRows)[row];
678
+ const snapshot = { ...draftRow.data };
679
+ setValue(snapshot, field.path, transformedValue);
363
680
 
364
- const schema = getSchema(currentRow);
365
- invariant(schema);
681
+ const validationErrors = this.validateDraftRowData(snapshot);
682
+ // TODO(thure): These errors sometimes result in a useless message like “is missing” (what is missing?)
683
+ if (validationErrors.length > 0) {
684
+ const error = validationErrors.find((err) => err.path === field.path);
685
+ if (error) {
686
+ return { valid: false, error: error.message };
687
+ }
688
+ }
689
+ return { valid: true };
690
+ } else {
691
+ const currentRow = this._registry.get(this._rows)[row];
692
+ invariant(currentRow, 'Invalid row index');
366
693
 
367
- const validationResult = validateSchema(schema, snapshot);
368
- if (validationResult && validationResult.length > 0) {
369
- const error = validationResult[0];
370
- invariant(error.path === field.path);
371
- return { valid: false, error: error.message };
372
- }
694
+ const snapshot = { ...getSnapshot(currentRow) };
695
+ setValue(snapshot, field.path, transformedValue);
373
696
 
374
- return { valid: true };
697
+ const schema = Obj.getSchema(currentRow);
698
+ invariant(schema);
699
+
700
+ const validationResult = validateSchema(schema, snapshot);
701
+ if (validationResult && validationResult.length > 0) {
702
+ const error = validationResult[0];
703
+ invariant(error.path === field.path);
704
+ return { valid: false, error: error.message };
705
+ }
706
+
707
+ return { valid: true };
708
+ }
375
709
  };
376
710
 
377
- public setCellData = ({ col, row }: DxGridPlanePosition, value: any): void => {
378
- const rowIdx = this._sorting.getDataIndex(row);
379
- const fields = this._view?.fields ?? [];
711
+ public setCellData = (cell: DxGridPosition, value: any): void => {
712
+ const { col, row, plane } = cell;
713
+ const fields = this._projection?.getFields() ?? [];
380
714
  if (col < 0 || col >= fields.length) {
381
715
  return;
382
716
  }
@@ -386,13 +720,25 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
386
720
  const transformedValue = editorTextToCellValue(props, value);
387
721
 
388
722
  // Special handling for Ref format to preserve existing behavior
389
- if (props.format === FormatEnum.Ref && !isLiveObject(value)) {
390
- // TODO(ZaymonFC): This get's called an additional time by the cell editor onBlur, but with the cell editors
391
- // plain string value. Maybe onBlur should be called with the actual value?
723
+ if (props.format === Format.TypeFormat.Ref && !Obj.isObject(value)) {
392
724
  return;
393
725
  }
394
726
 
395
- setValue(this._rows.value[rowIdx], field.path, transformedValue);
727
+ if (plane === 'frozenRowsEnd') {
728
+ // Update draft row data (draft rows are plain objects, not ECHO objects).
729
+ if (row >= 0 && row < this._registry.get(this._draftRows).length) {
730
+ setValue(this._registry.get(this._draftRows)[row].data, field.path, transformedValue);
731
+ // Re-validate the draft row after the update
732
+ this.validateDraftRow(row);
733
+ }
734
+ } else {
735
+ // Update regular row data (use sorted rows for display index)
736
+ const sortedRows = this._registry.get(this._sortedRows);
737
+ const rowData = sortedRows[row];
738
+ this._change.row(rowData, (mutableRow) => {
739
+ setValue(mutableRow, field.path, transformedValue);
740
+ });
741
+ }
396
742
  };
397
743
 
398
744
  /**
@@ -401,13 +747,43 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
401
747
  * @param {(value: any) => any} update - A function that takes the current value and returns the updated value.
402
748
  */
403
749
  public updateCellData({ col, row }: DxGridPlanePosition, update: (value: any) => any): void {
404
- const dataRow = this._sorting.getDataIndex(row);
405
- const fields = this._view?.fields ?? [];
750
+ const fields = this._projection?.getFields() ?? [];
406
751
  const field = fields[col];
752
+ const sortedRows = this._registry.get(this._sortedRows);
753
+ const rowData = sortedRows[row];
407
754
 
408
- const value = getValue(this._rows.value[dataRow], field.path);
755
+ const value = getValue(rowData, field.path);
409
756
  const updatedValue = update(value);
410
- setValue(this._rows.value[dataRow], field.path, updatedValue);
757
+ this._change.row(rowData, (mutableRow) => {
758
+ setValue(mutableRow, field.path, updatedValue);
759
+ });
760
+ }
761
+
762
+ /**
763
+ * Gets the domain-appropriate cell type for a given cell position.
764
+ * This abstracts away the low-level grid plane concepts and provides domain-specific types.
765
+ * @param cell - The cell position to classify
766
+ * @returns The domain-appropriate cell type
767
+ */
768
+ public getCellType(cell: DxGridPosition): TableCellType {
769
+ switch (cell.plane) {
770
+ case 'frozenRowsEnd':
771
+ return 'draft';
772
+ case 'frozenRowsStart':
773
+ return 'header';
774
+ case 'grid':
775
+ default:
776
+ return 'standard';
777
+ }
778
+ }
779
+
780
+ /**
781
+ * Convenience method to check if a cell is a draft cell.
782
+ * @param cell - The cell position to check
783
+ * @returns true if the cell is a draft cell, false otherwise
784
+ */
785
+ public isDraftCell(cell: DxGridPosition): boolean {
786
+ return this.getCellType(cell) === 'draft';
411
787
  }
412
788
 
413
789
  //
@@ -415,13 +791,13 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
415
791
  //
416
792
 
417
793
  public deleteColumn(fieldId: string): void {
418
- if (!this._view) {
794
+ if (!this._projection) {
419
795
  return;
420
796
  }
421
797
 
422
- const field = this._view?.fields.find((field) => field.id === fieldId);
423
- if (field && this._onDeleteColumn) {
424
- this._onDeleteColumn(field.id);
798
+ const field = this._projection?.getFields().find((field) => field.id === fieldId);
799
+ if (field && this._onColumnDelete) {
800
+ this._onColumnDelete(field.id);
425
801
  }
426
802
  }
427
803
 
@@ -430,12 +806,14 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
430
806
  //
431
807
 
432
808
  public setColumnWidth(columnIndex: number, width: number): void {
433
- const fields = this._view?.fields ?? [];
809
+ const fields = this._projection?.getFields() ?? [];
434
810
  if (columnIndex < fields.length) {
435
811
  const newWidth = Math.max(0, width);
436
812
  const field = fields[columnIndex];
437
813
  if (field) {
438
- field.size = newWidth;
814
+ this._change.table((mutableTable) => {
815
+ mutableTable.sizes[field.path] = newWidth;
816
+ });
439
817
  }
440
818
  }
441
819
  }
@@ -455,24 +833,112 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
455
833
  }
456
834
 
457
835
  //
458
- // View operations
836
+ // Sort operations
459
837
  //
838
+
839
+ /**
840
+ * Sets the sort order for a field in-memory (local to this instance).
841
+ * Use saveView() to persist the sort to the view query.
842
+ */
843
+ public setSort(fieldId: string, direction: QueryAST.OrderDirection): void {
844
+ const field = this._projection.getFields().find((f) => f.id === fieldId);
845
+ if (!field) {
846
+ return;
847
+ }
848
+
849
+ // Update in-memory sort (local changes).
850
+ this._registry.set(this._inMemorySort, {
851
+ fieldId,
852
+ direction,
853
+ });
854
+ }
855
+
856
+ /**
857
+ * Toggles the sort direction for a field in-memory.
858
+ */
859
+ public toggleSort(fieldId: string): void {
860
+ const currentSort = this.getSorting();
861
+ if (!currentSort || currentSort.fieldId !== fieldId) {
862
+ this.setSort(fieldId, 'asc');
863
+ return;
864
+ }
865
+
866
+ const newDirection = currentSort.direction === 'asc' ? 'desc' : 'asc';
867
+ this.setSort(fieldId, newDirection);
868
+ }
869
+
870
+ /**
871
+ * Clears the in-memory sort order.
872
+ */
873
+ public clearSort(): void {
874
+ this._registry.set(this._inMemorySort, undefined);
875
+ }
876
+
877
+ /**
878
+ * Saves the current in-memory sort to the view query AST.
879
+ * This persists the sort so it becomes the default for all viewers.
880
+ */
460
881
  public saveView(): void {
461
- this._sorting.save();
882
+ const view = this.view;
883
+ const inMemorySort = this._registry.get(this._inMemorySort);
884
+
885
+ // Snapshot view before accessing query.ast
886
+ const viewSnapshot = Obj.getSnapshot(view);
887
+ const currentQuery = Query.fromAst(viewSnapshot.query.ast);
888
+ const baseQuery = this._getBaseQuery(currentQuery);
889
+
890
+ if (inMemorySort) {
891
+ // Find the field to get its path
892
+ const field = this._projection.getFields().find((f) => f.id === inMemorySort.fieldId);
893
+ if (field) {
894
+ // Persist sort to view.query.ast
895
+ const newQuery = baseQuery.orderBy(Order.property<any>(field.path as string, inMemorySort.direction));
896
+ Obj.change(view, (v) => {
897
+ v.query.ast = newQuery.ast as Mutable<typeof newQuery.ast>;
898
+ });
899
+ }
900
+ } else {
901
+ // Clear sort from view.query.ast
902
+ Obj.change(view, (v) => {
903
+ v.query.ast = baseQuery.ast as Mutable<typeof baseQuery.ast>;
904
+ });
905
+ }
906
+
907
+ // Clear in-memory sort since it's now persisted.
908
+ this._registry.set(this._inMemorySort, undefined);
909
+ }
910
+
911
+ /**
912
+ * Extracts the base query without order clauses.
913
+ */
914
+ private _getBaseQuery(query: Query.Any): Query.Any {
915
+ const ast = query.ast;
916
+ // If the query has an order clause, extract the inner query
917
+ if (ast.type === 'order') {
918
+ return Query.fromAst(ast.query);
919
+ }
920
+ return query;
921
+ }
922
+
923
+ /**
924
+ * Checks if the view has unsaved changes (in-memory sort differs from persisted sort).
925
+ */
926
+ public getViewDirty(): boolean {
927
+ return this._registry.get(this._viewDirty);
462
928
  }
463
929
  }
464
930
 
465
931
  const editorTextToCellValue = (props: PropertyType, value: any): any => {
466
932
  switch (props.format) {
467
- case FormatEnum.Ref: {
468
- if (isLiveObject(value)) {
469
- return makeRef(value);
933
+ case Format.TypeFormat.Ref: {
934
+ if (Obj.isObject(value)) {
935
+ return Ref.make(value);
470
936
  } else {
471
937
  return value;
472
938
  }
473
939
  }
474
940
 
475
- case FormatEnum.SingleSelect: {
941
+ case Format.TypeFormat.SingleSelect: {
476
942
  const ids = extractTagIds(value);
477
943
  if (ids && ids.length > 0) {
478
944
  return ids[0];
@@ -481,7 +947,7 @@ const editorTextToCellValue = (props: PropertyType, value: any): any => {
481
947
  }
482
948
  }
483
949
 
484
- case FormatEnum.MultiSelect: {
950
+ case Format.TypeFormat.MultiSelect: {
485
951
  const ids = extractTagIds(value);
486
952
  return ids || value;
487
953
  }