@dxos/react-ui-table 0.8.4-main.7ace549 → 0.8.4-main.8baae0fced

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 (157) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/chunk-4URYOFDL.mjs +69 -0
  3. package/dist/lib/browser/chunk-4URYOFDL.mjs.map +7 -0
  4. package/dist/lib/browser/chunk-J5LGTIGS.mjs +10 -0
  5. package/dist/lib/browser/chunk-J5LGTIGS.mjs.map +7 -0
  6. package/dist/lib/browser/{chunk-KNC7XYT6.mjs → chunk-YDXPC7HA.mjs} +611 -699
  7. package/dist/lib/browser/chunk-YDXPC7HA.mjs.map +7 -0
  8. package/dist/lib/browser/index.mjs +1109 -1033
  9. package/dist/lib/browser/index.mjs.map +4 -4
  10. package/dist/lib/browser/meta.json +1 -1
  11. package/dist/lib/browser/testing/index.mjs +25 -22
  12. package/dist/lib/browser/testing/index.mjs.map +3 -3
  13. package/dist/lib/browser/translations.mjs +36 -0
  14. package/dist/lib/browser/translations.mjs.map +7 -0
  15. package/dist/lib/browser/types/index.mjs +2 -1
  16. package/dist/lib/node-esm/{chunk-CWSGS3Y2.mjs → chunk-2AN3QQ3D.mjs} +611 -698
  17. package/dist/lib/node-esm/chunk-2AN3QQ3D.mjs.map +7 -0
  18. package/dist/lib/node-esm/chunk-B2SZYAEC.mjs +70 -0
  19. package/dist/lib/node-esm/chunk-B2SZYAEC.mjs.map +7 -0
  20. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +11 -0
  21. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +7 -0
  22. package/dist/lib/node-esm/index.mjs +1109 -1033
  23. package/dist/lib/node-esm/index.mjs.map +4 -4
  24. package/dist/lib/node-esm/meta.json +1 -1
  25. package/dist/lib/node-esm/testing/index.mjs +25 -22
  26. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  27. package/dist/lib/node-esm/translations.mjs +37 -0
  28. package/dist/lib/node-esm/translations.mjs.map +7 -0
  29. package/dist/lib/node-esm/types/index.mjs +2 -1
  30. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts +1 -2
  31. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  32. package/dist/types/src/components/Table/ColumnSettings.d.ts +2 -2
  33. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  34. package/dist/types/src/components/Table/DynamicTable.d.ts +6 -7
  35. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  36. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +21 -37
  37. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/Table/Relations.stories.d.ts +84 -81
  39. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -1
  40. package/dist/types/src/components/Table/RowActionsMenu.d.ts.map +1 -1
  41. package/dist/types/src/components/Table/Table.d.ts +20 -21
  42. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  43. package/dist/types/src/components/Table/Table.stories.d.ts +86 -83
  44. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/Table/TableContent.d.ts +18 -0
  46. package/dist/types/src/components/Table/TableContent.d.ts.map +1 -0
  47. package/dist/types/src/components/Table/TableToolbar.d.ts +21 -0
  48. package/dist/types/src/components/Table/TableToolbar.d.ts.map +1 -0
  49. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +1 -1
  50. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -1
  51. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +10 -12
  52. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  53. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +5 -7
  54. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  55. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +24 -26
  56. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  57. package/dist/types/src/components/index.d.ts +0 -1
  58. package/dist/types/src/components/index.d.ts.map +1 -1
  59. package/dist/types/src/components/testing.d.ts +22 -13
  60. package/dist/types/src/components/testing.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useAddRow.d.ts +5 -6
  62. package/dist/types/src/hooks/useAddRow.d.ts.map +1 -1
  63. package/dist/types/src/hooks/useProjectionModel.d.ts +2 -1
  64. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -1
  65. package/dist/types/src/hooks/useTableModel.d.ts +6 -5
  66. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  67. package/dist/types/src/index.d.ts +0 -2
  68. package/dist/types/src/index.d.ts.map +1 -1
  69. package/dist/types/src/model/modal-controller.d.ts +5 -1
  70. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  71. package/dist/types/src/model/selection-model.d.ts +18 -9
  72. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  73. package/dist/types/src/model/table-model.d.ts +99 -18
  74. package/dist/types/src/model/table-model.d.ts.map +1 -1
  75. package/dist/types/src/model/table-presentation.d.ts +3 -1
  76. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  77. package/dist/types/src/playwright/TableManager.d.ts +2 -1
  78. package/dist/types/src/playwright/TableManager.d.ts.map +1 -1
  79. package/dist/types/src/playwright/playwright.config.d.ts.map +1 -1
  80. package/dist/types/src/testing/useTestTableModel.d.ts +5 -8
  81. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -1
  82. package/dist/types/src/translations.d.ts +21 -23
  83. package/dist/types/src/translations.d.ts.map +1 -1
  84. package/dist/types/src/types/Table.d.ts +12 -15
  85. package/dist/types/src/types/Table.d.ts.map +1 -1
  86. package/dist/types/src/util/dynamic-table.d.ts +12 -10
  87. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  88. package/dist/types/src/util/dynamic-table.test.d.ts +2 -0
  89. package/dist/types/src/util/dynamic-table.test.d.ts.map +1 -0
  90. package/dist/types/src/util/index.d.ts +2 -0
  91. package/dist/types/src/util/index.d.ts.map +1 -1
  92. package/dist/types/src/util/query-ast.d.ts +7 -0
  93. package/dist/types/src/util/query-ast.d.ts.map +1 -0
  94. package/dist/types/src/util/schema.d.ts.map +1 -1
  95. package/dist/types/src/util/signals.d.ts.map +1 -1
  96. package/dist/types/src/util/sort.d.ts +12 -0
  97. package/dist/types/src/util/sort.d.ts.map +1 -0
  98. package/dist/types/src/util/table-buttons.d.ts +20 -20
  99. package/dist/types/src/util/table-buttons.d.ts.map +1 -1
  100. package/dist/types/src/util/table-controls.d.ts +4 -4
  101. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  102. package/dist/types/src/util/tag.d.ts.map +1 -1
  103. package/dist/types/tsconfig.tsbuildinfo +1 -1
  104. package/package.json +67 -77
  105. package/src/components/Table/ColumnActionsMenu.tsx +17 -12
  106. package/src/components/Table/ColumnSettings.tsx +8 -9
  107. package/src/components/Table/DynamicTable.stories.tsx +25 -25
  108. package/src/components/Table/DynamicTable.tsx +31 -40
  109. package/src/components/Table/Relations.stories.tsx +96 -53
  110. package/src/components/Table/RowActionsMenu.tsx +6 -4
  111. package/src/components/Table/Table.stories.tsx +91 -81
  112. package/src/components/Table/Table.tsx +43 -407
  113. package/src/components/Table/TableContent.tsx +425 -0
  114. package/src/components/{TableToolbar → Table}/TableToolbar.tsx +44 -45
  115. package/src/components/TableCellEditor/CellValidationMessage.tsx +5 -6
  116. package/src/components/TableCellEditor/FormCellEditor.tsx +120 -136
  117. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +18 -16
  118. package/src/components/TableCellEditor/TableCellEditor.tsx +31 -29
  119. package/src/components/index.ts +0 -1
  120. package/src/components/testing.ts +42 -16
  121. package/src/hooks/useAddRow.ts +8 -10
  122. package/src/hooks/useProjectionModel.ts +29 -9
  123. package/src/hooks/useTableModel.ts +27 -23
  124. package/src/index.ts +0 -4
  125. package/src/model/modal-controller.ts +23 -12
  126. package/src/model/selection-model.ts +75 -44
  127. package/src/model/table-model.test.ts +81 -54
  128. package/src/model/table-model.ts +410 -139
  129. package/src/model/table-presentation.test.ts +58 -49
  130. package/src/model/table-presentation.ts +34 -30
  131. package/src/playwright/TableManager.ts +12 -5
  132. package/src/playwright/playwright.config.ts +1 -1
  133. package/src/playwright/smoke.spec.ts +51 -7
  134. package/src/testing/useTestTableModel.ts +26 -27
  135. package/src/translations.ts +20 -22
  136. package/src/types/Table.ts +28 -39
  137. package/src/util/dynamic-table.test.ts +70 -0
  138. package/src/util/dynamic-table.ts +35 -19
  139. package/src/util/index.ts +2 -0
  140. package/src/util/query-ast.ts +22 -0
  141. package/src/util/sort.ts +90 -0
  142. package/src/util/table-buttons.ts +1 -1
  143. package/src/util/table-controls.ts +2 -2
  144. package/dist/lib/browser/chunk-5XWSB3JP.mjs +0 -71
  145. package/dist/lib/browser/chunk-5XWSB3JP.mjs.map +0 -7
  146. package/dist/lib/browser/chunk-KNC7XYT6.mjs.map +0 -7
  147. package/dist/lib/node-esm/chunk-CWSGS3Y2.mjs.map +0 -7
  148. package/dist/lib/node-esm/chunk-MKTMOY46.mjs +0 -72
  149. package/dist/lib/node-esm/chunk-MKTMOY46.mjs.map +0 -7
  150. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts +0 -21
  151. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts.map +0 -1
  152. package/dist/types/src/components/TableToolbar/index.d.ts +0 -2
  153. package/dist/types/src/components/TableToolbar/index.d.ts.map +0 -1
  154. package/dist/types/src/model/table-sorting.d.ts +0 -56
  155. package/dist/types/src/model/table-sorting.d.ts.map +0 -1
  156. package/src/components/TableToolbar/index.ts +0 -5
  157. package/src/model/table-sorting.ts +0 -234
@@ -2,14 +2,15 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type ReadonlySignal, computed, effect, signal } from '@preact/signals-core';
5
+ import { Atom, type Registry } from '@effect-atom/atom-react';
6
6
 
7
7
  import { Resource } from '@dxos/context';
8
- import { Format, Obj, Ref } from '@dxos/echo';
9
- import { type JsonProp, type JsonSchemaType, getValue, setValue, toEffectSchema } from '@dxos/echo/internal';
8
+ import { type Database, Format, Obj, Order, Query, type QueryAST, Ref, type View } 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';
10
12
  import { invariant } from '@dxos/invariant';
11
13
  import { ObjectId } from '@dxos/keys';
12
- import { getSnapshot, isLiveObject } from '@dxos/live-object';
13
14
  import { type Label } from '@dxos/react-ui';
14
15
  import { formatForEditing, parseValue } from '@dxos/react-ui-form';
15
16
  import {
@@ -18,21 +19,57 @@ import {
18
19
  type DxGridPlaneRange,
19
20
  type DxGridPosition,
20
21
  } from '@dxos/react-ui-grid';
21
- import {
22
- type FieldSortType,
23
- type ProjectionModel,
24
- type PropertyType,
25
- type ValidationError,
26
- type View,
27
- validateSchema,
28
- } from '@dxos/schema';
22
+ import { type ProjectionModel, type PropertyType, type ValidationError, validateSchema } from '@dxos/schema';
29
23
 
30
24
  import { type Table } from '../types';
31
- import { touch } from '../util';
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
+
34
37
  import { type SelectionMode, SelectionModel } from './selection-model';
35
- import { TableSorting } from './table-sorting';
38
+
39
+ /**
40
+ * Callback type for wrapping mutations in Obj.update().
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.update(table, (table) => mutate(table as unknown as Table.Table)),
56
+ row: (row, mutate) => {
57
+ if (Obj.isObject(row)) {
58
+ Obj.update(row, (row) => mutate(row 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
+ });
36
73
 
37
74
  // Domain types for cell classification
38
75
  export type TableCellType = 'standard' | 'draft' | 'header';
@@ -69,67 +106,89 @@ const defaultFeatures: TableFeatures = {
69
106
  export type InsertRowResult = 'draft' | 'final';
70
107
 
71
108
  export type TableModelProps<T extends TableRow = TableRow> = {
109
+ registry: Registry.Registry;
72
110
  object: Table.Table;
73
111
  projection: ProjectionModel;
112
+ db?: Database.Database;
113
+ /**
114
+ * Callbacks to wrap mutations in Obj.update().
115
+ * Use createEchoChangeCallback() for ECHO-backed objects or createDirectChangeCallback() for plain objects.
116
+ */
117
+ change?: TableChangeCallback<T>;
74
118
  features?: Partial<TableFeatures>;
75
- sorting?: FieldSortType[];
76
119
  initialSelection?: string[];
77
120
  pinnedRows?: { top: number[]; bottom: number[] };
78
121
  rowActions?: TableRowAction[];
79
122
  onResolveSchema?: (typename: string) => Promise<JsonSchemaType>;
80
123
  onInsertRow?: (data?: any) => InsertRowResult;
81
124
  onDeleteRows?: (index: number, obj: T[]) => void;
82
- onDeleteColumn?: (fieldId: string) => void;
125
+ onColumnDelete?: (fieldId: string) => void;
83
126
  onCellUpdate?: (cell: DxGridPosition) => void;
84
127
  onRowOrderChange?: () => void;
85
128
  onRowAction?: (actionId: string, data: T) => void;
86
129
  };
87
130
 
88
131
  export class TableModel<T extends TableRow = TableRow> extends Resource {
132
+ private readonly _registry: Registry.Registry;
89
133
  private readonly _object: Table.Table;
90
134
  private readonly _projection: ProjectionModel;
135
+ private readonly _db?: Database.Database;
136
+ private readonly _change: TableChangeCallback<T>;
91
137
 
92
- private readonly _visibleRange = signal<DxGridPlaneRange>({
93
- start: { row: 0, col: 0 },
94
- end: { row: 0, col: 0 },
95
- });
138
+ private readonly _visibleRange: Atom.Writable<DxGridPlaneRange>;
96
139
 
97
140
  private readonly _onInsertRow?: (data?: any) => InsertRowResult;
98
141
  private readonly _onDeleteRows?: TableModelProps<T>['onDeleteRows'];
99
- private readonly _onDeleteColumn?: TableModelProps<T>['onDeleteColumn'];
142
+ private readonly _onColumnDelete?: TableModelProps<T>['onColumnDelete'];
100
143
  private readonly _onCellUpdate?: TableModelProps<T>['onCellUpdate'];
101
144
  private readonly _onRowOrderChange?: TableModelProps<T>['onRowOrderChange'];
102
145
  private readonly _onRowAction?: TableModelProps<T>['onRowAction'];
103
146
  private readonly _rowActions: TableRowAction[];
104
147
  private readonly _features: TableFeatures;
105
148
 
106
- private readonly _rows = signal<T[]>([]);
107
- private readonly _draftRows = signal<DraftRow<T>[]>([]);
108
- 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>;
109
161
 
110
162
  private _pinnedRows: NonNullable<TableModelProps<T>['pinnedRows']>;
111
163
  private _selection!: SelectionModel<T>;
112
- 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>;
113
167
 
114
168
  constructor({
169
+ registry,
115
170
  object,
116
171
  projection,
172
+ db,
173
+ change,
117
174
  features = {},
118
- sorting = [],
119
175
  initialSelection = [],
120
176
  pinnedRows = { top: [], bottom: [] },
121
177
  rowActions = [],
122
178
  onCellUpdate,
123
- onDeleteColumn,
179
+ onColumnDelete,
124
180
  onDeleteRows,
125
181
  onInsertRow,
126
182
  onRowOrderChange,
127
183
  onRowAction,
128
184
  }: TableModelProps<T>) {
129
185
  super();
186
+ this._registry = registry;
130
187
  this._object = object;
131
188
  this._projection = projection;
132
189
  this._projection.normalizeView();
190
+ this._db = db;
191
+ this._change = change ?? createEchoChangeCallback<T>(object);
133
192
 
134
193
  // TODO(ZaymonFC): Use our more robust config merging module?
135
194
  this._features = { ...defaultFeatures, ...features };
@@ -139,29 +198,101 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
139
198
  'Single selection is not compatible with editable tables.',
140
199
  );
141
200
 
142
- this._sorting = new TableSorting({
143
- rows: this._rows,
144
- getView: () => this.view,
145
- projection: this._projection,
201
+ // Initialize writable atoms.
202
+ this._visibleRange = Atom.make<DxGridPlaneRange>({
203
+ start: { row: 0, col: 0 },
204
+ end: { row: 0, col: 0 },
146
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
+ }
147
232
 
148
- if (sorting.length > 0) {
149
- const [sort] = sorting;
150
- this._sorting.setSort(sort.fieldId, sort.direction);
151
- }
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
+ });
152
267
 
153
268
  this._selection = new SelectionModel(
154
- this._sorting.sortedRows,
269
+ this._registry,
270
+ this._sortedRows,
155
271
  this._features.selection.mode ?? 'multiple',
156
272
  initialSelection,
157
273
  () => this._onRowOrderChange?.(),
158
274
  );
159
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
+ }
282
+
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
+ });
290
+
160
291
  this._pinnedRows = pinnedRows;
161
292
  this._rowActions = rowActions;
162
293
  this._onInsertRow = onInsertRow;
163
294
  this._onDeleteRows = onDeleteRows;
164
- this._onDeleteColumn = onDeleteColumn;
295
+ this._onColumnDelete = onColumnDelete;
165
296
  this._onCellUpdate = onCellUpdate;
166
297
  this._onRowOrderChange = onRowOrderChange;
167
298
  this._onRowAction = onRowAction;
@@ -185,8 +316,36 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
185
316
  return this._projection;
186
317
  }
187
318
 
188
- public get rows(): ReadonlySignal<T[]> {
189
- 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);
190
349
  }
191
350
 
192
351
  public get features(): TableFeatures {
@@ -194,28 +353,30 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
194
353
  }
195
354
 
196
355
  /**
197
- * @reactive
356
+ * Gets the current sort state.
357
+ * Priority: in-memory sort (local changes) > persisted sort (from query AST).
198
358
  */
199
- public get sorting(): TableSorting<T> {
200
- return this._sorting;
359
+ public getSorting(): FieldSortType | undefined {
360
+ return this._registry.get(this._sorting);
201
361
  }
202
362
 
203
363
  /**
204
364
  * Gets the row data at the specified display index.
205
365
  */
206
366
  public getRowAt(displayIndex: number): T | undefined {
207
- if (displayIndex < 0 || displayIndex >= this.sorting.sortedRows.value.length) {
367
+ const sortedRows = this._registry.get(this._sortedRows);
368
+ if (displayIndex < 0 || displayIndex >= sortedRows.length) {
208
369
  return undefined;
209
370
  }
210
371
 
211
- return this.sorting.sortedRows.value[displayIndex];
372
+ return sortedRows[displayIndex];
212
373
  }
213
374
 
214
375
  public get pinnedRows(): NonNullable<TableModelProps<T>['pinnedRows']> {
215
376
  return this._pinnedRows;
216
377
  }
217
378
 
218
- public get columnMeta(): ReadonlySignal<DxGridAxisMeta> {
379
+ public get columnMeta(): Atom.Atom<DxGridAxisMeta> {
219
380
  invariant(this._columnMeta);
220
381
  return this._columnMeta;
221
382
  }
@@ -224,11 +385,6 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
224
385
  return this._selection;
225
386
  }
226
387
 
227
- /** @reactive */
228
- public get isViewDirty(): boolean {
229
- return this.sorting.isDirty;
230
- }
231
-
232
388
  public get rowActions(): TableRowAction[] {
233
389
  return this._rowActions;
234
390
  }
@@ -245,8 +401,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
245
401
  }
246
402
 
247
403
  private initializeColumnMeta(): void {
248
- this._columnMeta = computed(() => {
249
- const fields = this._projection?.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);
250
410
  const meta = Object.fromEntries(
251
411
  fields.map((field, index: number) => [index, { size: this.table.sizes[field.path] ?? 256, resizeable: true }]),
252
412
  );
@@ -260,40 +420,51 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
260
420
  }
261
421
 
262
422
  private initializeEffects(): void {
263
- const rowOrderWatcher = effect(() => {
264
- touch(this.sorting.sortedRows.value);
423
+ // Subscribe to rows changes to notify row order changed.
424
+ const rowsUnsubscribe = this._registry.subscribe(this._rows, () => {
265
425
  this._onRowOrderChange?.();
266
426
  });
267
- this._ctx.onDispose(rowOrderWatcher);
268
-
269
- /**
270
- * Creates reactive effects for each row in the visible range.
271
- * Subscribes to changes in the visible range, recreating row effects when it changes.
272
- * When a row's data changes, invokes onCellUpdate to notify subscribers and trigger UI updates.
273
- */
274
- const rowEffectManager = effect(() => {
275
- const { start, end } = touch(this._visibleRange.value);
276
- const rowEffects: ReturnType<typeof effect>[] = [];
277
- for (let row = start.row; row <= end.row; row++) {
278
- rowEffects.push(
279
- effect(() => {
280
- const obj = this.sorting.sortedRows.value[row];
281
- this._projection?.fields.forEach((field) => touch(getValue(obj, field.path)));
282
- this._onCellUpdate?.({ row, col: start.col, plane: 'grid' });
283
- }),
284
- );
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);
285
438
  }
286
439
 
287
- return () => rowEffects.forEach((cleanup) => cleanup());
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);
450
+ this._onCellUpdate?.({ row, col: start.col, plane: 'grid' });
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();
460
+ }
288
461
  });
289
- this._ctx.onDispose(rowEffectManager);
290
462
 
291
- const draftRowsWatcher = effect(() => {
292
- touch(this._draftRows.value);
293
- // Notify grid that draft rows have changed
463
+ // Subscribe to draft rows changes to notify grid.
464
+ const draftRowsUnsubscribe = this._registry.subscribe(this._draftRows, () => {
294
465
  this._onRowOrderChange?.();
295
466
  });
296
- this._ctx.onDispose(draftRowsWatcher);
467
+ this._ctx.onDispose(draftRowsUnsubscribe);
297
468
  }
298
469
 
299
470
  //
@@ -301,21 +472,25 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
301
472
  //
302
473
 
303
474
  public setRows = (obj: T[]): void => {
304
- this._rows.value = obj;
475
+ this._registry.set(this._rows, obj);
305
476
  };
306
477
 
307
- public getRowCount = (): number => this._rows.value.length;
478
+ public getRowCount = (): number => this._registry.get(this._sortedRows).length;
308
479
 
309
- /**
310
- * @reactive
311
- */
312
- public getDraftRowCount = (): number => this._draftRows.value.length;
480
+ public getDraftRowCount(): number {
481
+ return this._registry.get(this._draftRows).length;
482
+ }
313
483
 
314
- // TODO(ZaymonFC): Return .value instead of exposing the signal.
315
- public get draftRows(): ReadonlySignal<DraftRow<T>[]> {
484
+ /** Atom for reactive access to draft rows. */
485
+ public get draftRows(): Atom.Atom<DraftRow<T>[]> {
316
486
  return this._draftRows;
317
487
  }
318
488
 
489
+ /** Gets the current draft rows. */
490
+ public getDraftRows(): DraftRow<T>[] {
491
+ return this._registry.get(this._draftRows);
492
+ }
493
+
319
494
  /**
320
495
  * Checks if a specific field path has validation errors for a given draft row.
321
496
  * @param draftRowIndex - The index of the draft row to check
@@ -323,20 +498,20 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
323
498
  * @returns true if the field has validation errors, false otherwise
324
499
  */
325
500
  public hasDraftRowValidationError(draftRowIndex: number, fieldPath: string): boolean {
326
- if (draftRowIndex < 0 || draftRowIndex >= this._draftRows.value.length) {
501
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
327
502
  return false;
328
503
  }
329
504
 
330
- const draftRow = this._draftRows.value[draftRowIndex];
505
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
331
506
  const validationErrors = draftRow.validationErrors || [];
332
507
  return validationErrors.some((error) => error.path === fieldPath);
333
508
  }
334
509
 
335
- public getColumnCount = (): number => this._projection?.fields.length ?? 0;
510
+ public getColumnCount = (): number => this._projection?.getFields().length ?? 0;
336
511
 
337
512
  public insertRow = (): InsertRowResult => {
338
513
  const result = this._onInsertRow?.();
339
- if (result === 'draft' && this._draftRows.value.length === 0) {
514
+ if (result === 'draft' && this._registry.get(this._draftRows).length === 0) {
340
515
  this.createDraftRow();
341
516
  }
342
517
  return result ?? 'final';
@@ -357,15 +532,15 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
357
532
  validationErrors,
358
533
  };
359
534
 
360
- this._draftRows.value = [...this._draftRows.value, draftRow];
535
+ this._registry.set(this._draftRows, [...this._registry.get(this._draftRows), draftRow]);
361
536
  }
362
537
 
363
538
  private validateDraftRow(draftRowIndex: number): void {
364
- if (draftRowIndex < 0 || draftRowIndex >= this._draftRows.value.length) {
539
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
365
540
  return;
366
541
  }
367
542
 
368
- const draftRow = this._draftRows.value[draftRowIndex];
543
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
369
544
  const validationErrors = this.validateDraftRowData(draftRow.data);
370
545
 
371
546
  const updatedDraftRow = {
@@ -374,9 +549,9 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
374
549
  validationErrors,
375
550
  };
376
551
 
377
- const newDraftRows = [...this._draftRows.value];
552
+ const newDraftRows = [...this._registry.get(this._draftRows)];
378
553
  newDraftRows[draftRowIndex] = updatedDraftRow;
379
- this._draftRows.value = newDraftRows;
554
+ this._registry.set(this._draftRows, newDraftRows);
380
555
  }
381
556
 
382
557
  private validateDraftRowData(data: T): ValidationError[] {
@@ -385,11 +560,11 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
385
560
  }
386
561
 
387
562
  public commitDraftRow = (draftRowIndex: number): boolean => {
388
- if (draftRowIndex < 0 || draftRowIndex >= this._draftRows.value.length) {
563
+ if (draftRowIndex < 0 || draftRowIndex >= this._registry.get(this._draftRows).length) {
389
564
  return false;
390
565
  }
391
566
 
392
- const draftRow = this._draftRows.value[draftRowIndex];
567
+ const draftRow = this._registry.get(this._draftRows)[draftRowIndex];
393
568
  if (!draftRow.valid) {
394
569
  return false;
395
570
  }
@@ -397,26 +572,28 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
397
572
  const insertRowResult = this._onInsertRow?.(draftRow.data);
398
573
 
399
574
  if (insertRowResult === 'final') {
400
- const newDraftRows = this._draftRows.value.filter((_, index) => index !== draftRowIndex);
401
- this._draftRows.value = newDraftRows;
575
+ const newDraftRows = this._registry.get(this._draftRows).filter((_, index) => index !== draftRowIndex);
576
+ this._registry.set(this._draftRows, newDraftRows);
402
577
  }
403
578
 
404
579
  return insertRowResult === 'final';
405
580
  };
406
581
 
407
582
  public deleteRow = (rowIndex: number): void => {
408
- const row = this._sorting.getDataIndex(rowIndex);
409
- const obj = this._rows.value[row];
583
+ const sortedRows = this._registry.get(this._sortedRows);
584
+ const obj = sortedRows[rowIndex];
410
585
  const objectsToDelete = [];
411
586
 
412
- if (this.features.selection.enabled && this._selection.hasSelection.value) {
587
+ if (this.features.selection.enabled && this._selection.hasSelection) {
413
588
  const selectedRows = this._selection.getSelectedRows();
414
589
  objectsToDelete.push(...selectedRows);
415
- } else {
590
+ } else if (obj) {
416
591
  objectsToDelete.push(obj);
417
592
  }
418
593
 
419
- this._onDeleteRows?.(row, objectsToDelete);
594
+ if (objectsToDelete.length > 0) {
595
+ this._onDeleteRows?.(rowIndex, objectsToDelete);
596
+ }
420
597
  };
421
598
 
422
599
  public handleRowAction = (actionId: string, rowIndex: number): void => {
@@ -424,8 +601,8 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
424
601
  return;
425
602
  }
426
603
 
427
- const row = this._sorting.getDataIndex(rowIndex);
428
- const data = this._rows.value[row];
604
+ const sortedRows = this._registry.get(this._sortedRows);
605
+ const data = sortedRows[rowIndex];
429
606
 
430
607
  if (data) {
431
608
  this._onRowAction(actionId, data);
@@ -434,7 +611,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
434
611
 
435
612
  public getCellData = (cell: DxGridPosition): any => {
436
613
  const { col, row, plane } = cell;
437
- const fields = this._projection?.fields ?? [];
614
+ const fields = this._projection?.getFields() ?? [];
438
615
  if (col < 0 || col >= fields.length) {
439
616
  return undefined;
440
617
  }
@@ -444,15 +621,15 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
444
621
 
445
622
  if (plane === 'frozenRowsEnd') {
446
623
  // Get data from draft row
447
- if (row >= 0 && row < this._draftRows.value.length) {
448
- value = getValue(this._draftRows.value[row].data, field.path);
624
+ if (row >= 0 && row < this._registry.get(this._draftRows).length) {
625
+ value = getValue(this._registry.get(this._draftRows)[row].data, field.path);
449
626
  } else {
450
627
  return undefined;
451
628
  }
452
629
  } else {
453
- // Get data from regular row
454
- const dataIndex = this._sorting.getDataIndex(row);
455
- value = getValue(this._rows.value[dataIndex], field.path);
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);
456
633
  }
457
634
 
458
635
  if (value == null) {
@@ -480,7 +657,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
480
657
  };
481
658
 
482
659
  public validateCellData = async ({ col, row, plane }: DxGridPosition, value: any): Promise<ValidationResult> => {
483
- const fields = this._projection?.fields ?? [];
660
+ const fields = this._projection?.getFields() ?? [];
484
661
  if (col < 0 || col >= fields.length) {
485
662
  return { valid: false, error: 'Invalid column index' };
486
663
  }
@@ -492,12 +669,12 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
492
669
  const isDraftRow = plane === 'frozenRowsEnd';
493
670
 
494
671
  if (isDraftRow) {
495
- const isInvalidDraftRowIndex = row < 0 || row >= this._draftRows.value.length;
672
+ const isInvalidDraftRowIndex = row < 0 || row >= this._registry.get(this._draftRows).length;
496
673
  if (isInvalidDraftRowIndex) {
497
674
  return { valid: false, error: 'Invalid draft row index' };
498
675
  }
499
676
 
500
- const draftRow = this._draftRows.value[row];
677
+ const draftRow = this._registry.get(this._draftRows)[row];
501
678
  const snapshot = { ...draftRow.data };
502
679
  setValue(snapshot, field.path, transformedValue);
503
680
 
@@ -511,14 +688,13 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
511
688
  }
512
689
  return { valid: true };
513
690
  } else {
514
- const rowIdx = this._sorting.getDataIndex(row);
515
- const currentRow = this._rows.value[rowIdx];
691
+ const currentRow = this._registry.get(this._rows)[row];
516
692
  invariant(currentRow, 'Invalid row index');
517
693
 
518
- const snapshot = getSnapshot(currentRow);
694
+ const snapshot = { ...getSnapshot(currentRow) };
519
695
  setValue(snapshot, field.path, transformedValue);
520
696
 
521
- const schema = Obj.getSchema(currentRow);
697
+ const schema = Obj.getSchema(currentRow as unknown as Obj.Unknown);
522
698
  invariant(schema);
523
699
 
524
700
  const validationResult = validateSchema(schema, snapshot);
@@ -534,7 +710,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
534
710
 
535
711
  public setCellData = (cell: DxGridPosition, value: any): void => {
536
712
  const { col, row, plane } = cell;
537
- const fields = this._projection?.fields ?? [];
713
+ const fields = this._projection?.getFields() ?? [];
538
714
  if (col < 0 || col >= fields.length) {
539
715
  return;
540
716
  }
@@ -544,21 +720,24 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
544
720
  const transformedValue = editorTextToCellValue(props, value);
545
721
 
546
722
  // Special handling for Ref format to preserve existing behavior
547
- if (props.format === Format.TypeFormat.Ref && !isLiveObject(value)) {
723
+ if (props.format === Format.TypeFormat.Ref && !Obj.isObject(value)) {
548
724
  return;
549
725
  }
550
726
 
551
727
  if (plane === 'frozenRowsEnd') {
552
- // Update draft row data
553
- if (row >= 0 && row < this._draftRows.value.length) {
554
- setValue(this._draftRows.value[row].data, field.path, transformedValue);
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);
555
731
  // Re-validate the draft row after the update
556
732
  this.validateDraftRow(row);
557
733
  }
558
734
  } else {
559
- // Update regular row data
560
- const rowIdx = this._sorting.getDataIndex(row);
561
- setValue(this._rows.value[rowIdx], field.path, transformedValue);
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
+ });
562
741
  }
563
742
  };
564
743
 
@@ -568,13 +747,16 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
568
747
  * @param {(value: any) => any} update - A function that takes the current value and returns the updated value.
569
748
  */
570
749
  public updateCellData({ col, row }: DxGridPlanePosition, update: (value: any) => any): void {
571
- const dataRow = this._sorting.getDataIndex(row);
572
- const fields = this._projection?.fields ?? [];
750
+ const fields = this._projection?.getFields() ?? [];
573
751
  const field = fields[col];
752
+ const sortedRows = this._registry.get(this._sortedRows);
753
+ const rowData = sortedRows[row];
574
754
 
575
- const value = getValue(this._rows.value[dataRow], field.path);
755
+ const value = getValue(rowData, field.path);
576
756
  const updatedValue = update(value);
577
- setValue(this._rows.value[dataRow], field.path, updatedValue);
757
+ this._change.row(rowData, (mutableRow) => {
758
+ setValue(mutableRow, field.path, updatedValue);
759
+ });
578
760
  }
579
761
 
580
762
  /**
@@ -613,9 +795,9 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
613
795
  return;
614
796
  }
615
797
 
616
- const field = this._projection?.fields.find((field) => field.id === fieldId);
617
- if (field && this._onDeleteColumn) {
618
- 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);
619
801
  }
620
802
  }
621
803
 
@@ -624,12 +806,14 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
624
806
  //
625
807
 
626
808
  public setColumnWidth(columnIndex: number, width: number): void {
627
- const fields = this._projection?.fields ?? [];
809
+ const fields = this._projection?.getFields() ?? [];
628
810
  if (columnIndex < fields.length) {
629
811
  const newWidth = Math.max(0, width);
630
812
  const field = fields[columnIndex];
631
813
  if (field) {
632
- this.table.sizes[field.path] = newWidth;
814
+ this._change.table((mutableTable) => {
815
+ mutableTable.sizes[field.path] = newWidth;
816
+ });
633
817
  }
634
818
  }
635
819
  }
@@ -649,18 +833,105 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
649
833
  }
650
834
 
651
835
  //
652
- // View operations
836
+ // Sort operations
653
837
  //
654
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
+ */
655
881
  public saveView(): void {
656
- 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.update(view, (view) => {
897
+ view.query.ast = newQuery.ast as Mutable<typeof newQuery.ast>;
898
+ });
899
+ }
900
+ } else {
901
+ // Clear sort from view.query.ast
902
+ Obj.update(view, (view) => {
903
+ view.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);
657
928
  }
658
929
  }
659
930
 
660
931
  const editorTextToCellValue = (props: PropertyType, value: any): any => {
661
932
  switch (props.format) {
662
933
  case Format.TypeFormat.Ref: {
663
- if (isLiveObject(value)) {
934
+ if (Obj.isObject(value)) {
664
935
  return Ref.make(value);
665
936
  } else {
666
937
  return value;