@dxos/react-ui-table 0.7.5-main.499c70c → 0.7.5-main.937ce75

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.
@@ -3,14 +3,13 @@
3
3
  //
4
4
 
5
5
  import { computed, effect, signal, type ReadonlySignal } from '@preact/signals-core';
6
- import orderBy from 'lodash.orderby';
7
6
 
8
7
  import { Resource } from '@dxos/context';
9
8
  import { getValue, setValue, FormatEnum, type JsonProp } from '@dxos/echo-schema';
10
9
  import { invariant } from '@dxos/invariant';
11
10
  import { isReactiveObject, makeRef } from '@dxos/live-object';
12
11
  import { PublicKey } from '@dxos/react-client';
13
- import { formatForDisplay, formatForEditing, parseValue } from '@dxos/react-ui-form';
12
+ import { formatForEditing, parseValue } from '@dxos/react-ui-form';
14
13
  import {
15
14
  type DxGridAxisMeta,
16
15
  type DxGridPlaneRange,
@@ -20,12 +19,10 @@ import {
20
19
  import { type ViewProjection } from '@dxos/schema';
21
20
 
22
21
  import { SelectionModel } from './selection-model';
22
+ import { type SortConfig, type SortDirection, TableSorting } from './table-sorting';
23
23
  import { type TableType } from '../types';
24
24
  import { touch } from '../util';
25
25
 
26
- export type SortDirection = 'asc' | 'desc';
27
- export type SortConfig = { fieldId: string; direction: SortDirection };
28
-
29
26
  export type BaseTableRow = Record<JsonProp, any> & { id: string };
30
27
 
31
28
  export type TableModelProps<T extends BaseTableRow = { id: string }> = {
@@ -51,25 +48,16 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
51
48
  end: { row: 0, col: 0 },
52
49
  });
53
50
 
54
- /**
55
- * Maps display indices to data indices.
56
- * Used for translating between sorted/displayed order and original data order.
57
- * Keys are display indices, values are corresponding data indices.
58
- */
59
- private readonly _displayToDataIndex: Map<number, number> = new Map();
60
-
61
51
  private readonly _onInsertRow?: TableModelProps<T>['onInsertRow'];
62
52
  private readonly _onDeleteRows?: TableModelProps<T>['onDeleteRows'];
63
53
  private readonly _onDeleteColumn?: TableModelProps<T>['onDeleteColumn'];
64
54
  private readonly _onCellUpdate?: TableModelProps<T>['onCellUpdate'];
65
55
  private readonly _onRowOrderChanged?: TableModelProps<T>['onRowOrderChanged'];
66
56
 
67
- private readonly _sorting = signal<SortConfig | undefined>(undefined);
68
- private _sortedRows!: ReadonlySignal<T[]>;
69
- private _rows = signal<T[]>([]);
57
+ private readonly _rows = signal<T[]>([]);
58
+ private readonly _sorting: TableSorting<T>;
70
59
 
71
60
  private _pinnedRows: NonNullable<TableModelProps<T>['pinnedRows']>;
72
-
73
61
  private _selection!: SelectionModel<T>;
74
62
  private _columnMeta?: ReadonlySignal<DxGridAxisMeta>;
75
63
 
@@ -87,8 +75,13 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
87
75
  super();
88
76
  this._table = table;
89
77
  this._projection = projection;
78
+ this._sorting = new TableSorting(this._rows, table, projection);
79
+
80
+ if (sorting.length > 0) {
81
+ const [sort] = sorting;
82
+ this._sorting.setSort(sort.fieldId, sort.direction);
83
+ }
90
84
 
91
- this._sorting.value = sorting.at(0);
92
85
  this._pinnedRows = pinnedRows;
93
86
  this._onInsertRow = onInsertRow;
94
87
  this._onDeleteRows = onDeleteRows;
@@ -106,7 +99,14 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
106
99
  }
107
100
 
108
101
  public get rows(): ReadonlySignal<T[]> {
109
- return this._sortedRows;
102
+ return this._sorting.sortedRows;
103
+ }
104
+
105
+ /**
106
+ * @reactive
107
+ */
108
+ public get sorting(): SortConfig | undefined {
109
+ return this._sorting.sorting;
110
110
  }
111
111
 
112
112
  public get pinnedRows(): NonNullable<TableModelProps<T>['pinnedRows']> {
@@ -118,10 +118,6 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
118
118
  return this._columnMeta;
119
119
  }
120
120
 
121
- public get sorting(): SortConfig | undefined {
122
- return this._sorting.value;
123
- }
124
-
125
121
  public get selection() {
126
122
  return this._selection;
127
123
  }
@@ -132,9 +128,8 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
132
128
 
133
129
  protected override async _open() {
134
130
  this.initializeColumnMeta();
135
- this.initializeSorting();
136
131
  this.initializeEffects();
137
- this._selection = new SelectionModel(this._sortedRows, () => this._onRowOrderChanged?.());
132
+ this._selection = new SelectionModel(this._sorting.sortedRows, () => this._onRowOrderChanged?.());
138
133
  await this._selection.open(this._ctx);
139
134
  }
140
135
 
@@ -153,59 +148,9 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
153
148
  });
154
149
  }
155
150
 
156
- private initializeSorting(): void {
157
- this._sortedRows = computed(() => {
158
- this._displayToDataIndex.clear();
159
- const sort = this._sorting.value;
160
- if (!sort) {
161
- return this._rows.value;
162
- }
163
-
164
- const field = this._table.view?.target?.fields.find((field) => field.id === sort.fieldId);
165
- if (!field) {
166
- return this._rows.value;
167
- }
168
-
169
- const { props } = this._projection.getFieldProjection(field.id);
170
-
171
- const dataWithIndices = this._rows.value.map((item, index) => {
172
- const rawValue = getValue(item, field.path);
173
- let sortValue = rawValue;
174
-
175
- if (props.type.startsWith('boolean')) {
176
- sortValue = !!rawValue; // Coerce null/undefined/false to false for booleans
177
- } else if (rawValue != null) {
178
- if (props.format === FormatEnum.Ref && field.referencePath) {
179
- sortValue = getValue(rawValue, field.referencePath);
180
- } else {
181
- sortValue = formatForDisplay({ type: props.type, format: props.format, value: rawValue });
182
- }
183
- }
184
-
185
- return {
186
- item,
187
- index,
188
- value: sortValue,
189
- isEmpty: !props.type.startsWith('boolean') && (sortValue == null || sortValue === ''),
190
- };
191
- });
192
-
193
- const sorted = orderBy(dataWithIndices, ['isEmpty', 'value'], ['asc', sort.direction]);
194
-
195
- for (let displayIndex = 0; displayIndex < sorted.length; displayIndex++) {
196
- const { index: dataIndex } = sorted[displayIndex];
197
- if (displayIndex !== dataIndex) {
198
- this._displayToDataIndex.set(displayIndex, dataIndex);
199
- }
200
- }
201
-
202
- return sorted.map(({ item }) => item);
203
- });
204
- }
205
-
206
151
  private initializeEffects(): void {
207
152
  const rowOrderWatcher = effect(() => {
208
- touch(this._sortedRows.value);
153
+ touch(this._sorting.sortedRows.value);
209
154
  this._onRowOrderChanged?.();
210
155
  });
211
156
  this._ctx.onDispose(rowOrderWatcher);
@@ -221,7 +166,7 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
221
166
  for (let row = start.row; row <= end.row; row++) {
222
167
  rowEffects.push(
223
168
  effect(() => {
224
- const obj = this._sortedRows.value[row];
169
+ const obj = this._sorting.sortedRows.value[row];
225
170
  this?._table?.view?.target?.fields.forEach((field) => touch(getValue(obj, field.path)));
226
171
  this._onCellUpdate?.({ row, col: start.col, plane: 'grid' });
227
172
  }),
@@ -246,12 +191,12 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
246
191
  public getColumnCount = (): number => this.table.view?.target?.fields.length ?? 0;
247
192
 
248
193
  public insertRow = (rowIndex?: number): void => {
249
- const row = rowIndex !== undefined ? this._displayToDataIndex.get(rowIndex) ?? rowIndex : this._rows.value.length;
194
+ const row = rowIndex !== undefined ? this._sorting.getDataIndex(rowIndex) : this._rows.value.length;
250
195
  this._onInsertRow?.(row);
251
196
  };
252
197
 
253
198
  public deleteRow = (rowIndex: number): void => {
254
- const row = this._displayToDataIndex.get(rowIndex) ?? rowIndex;
199
+ const row = this._sorting.getDataIndex(rowIndex);
255
200
  const obj = this._rows.value[row];
256
201
  const objectsToDelete = [];
257
202
 
@@ -272,7 +217,7 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
272
217
  }
273
218
 
274
219
  const field = fields[col];
275
- const dataIndex = this._displayToDataIndex.get(row) ?? row;
220
+ const dataIndex = this._sorting.getDataIndex(row);
276
221
  const value = getValue(this._rows.value[dataIndex], field.path);
277
222
  if (value == null) {
278
223
  return '';
@@ -295,7 +240,7 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
295
240
  };
296
241
 
297
242
  public setCellData = ({ col, row }: DxGridPlanePosition, value: any): void => {
298
- const rowIdx = this._displayToDataIndex.get(row) ?? row;
243
+ const rowIdx = this._sorting.getDataIndex(row);
299
244
  const fields = this.table.view?.target?.fields ?? [];
300
245
  if (col < 0 || col >= fields.length) {
301
246
  return;
@@ -334,7 +279,7 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
334
279
  * @param {(value: any) => any} update - A function that takes the current value and returns the updated value.
335
280
  */
336
281
  public updateCellData({ col, row }: DxGridPlanePosition, update: (value: any) => any): void {
337
- const dataRow = this._displayToDataIndex.get(row) ?? row;
282
+ const dataRow = this._sorting.getDataIndex(row);
338
283
  const fields = this.table.view?.target?.fields ?? [];
339
284
  const field = fields[col];
340
285
 
@@ -354,9 +299,6 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
354
299
 
355
300
  const field = this.table.view.target?.fields.find((field) => field.id === fieldId);
356
301
  if (field && this._onDeleteColumn) {
357
- if (this._sorting.value?.fieldId === fieldId) {
358
- this.clearSort();
359
- }
360
302
  this._onDeleteColumn(field.id);
361
303
  }
362
304
  }
@@ -381,11 +323,11 @@ export class TableModel<T extends BaseTableRow = { id: string }> extends Resourc
381
323
  //
382
324
 
383
325
  public setSort(fieldId: string, direction: SortDirection): void {
384
- this._sorting.value = { fieldId, direction };
326
+ this._sorting.setSort(fieldId, direction);
385
327
  }
386
328
 
387
329
  public clearSort(): void {
388
- this._sorting.value = undefined;
330
+ this._sorting.setSort('', 'asc');
389
331
  }
390
332
 
391
333
  //
@@ -148,6 +148,7 @@ export class TablePresentation<T extends BaseTableRow = { id: string }> {
148
148
  readonly: true,
149
149
  resizeHandle: 'col',
150
150
  accessoryHtml: tableButtons.columnSettings.render({ fieldId: field.id }),
151
+ className: '!bg-gridHeader',
151
152
  };
152
153
  }
153
154
 
@@ -189,13 +190,14 @@ export class TablePresentation<T extends BaseTableRow = { id: string }> {
189
190
  private getSelectAllCell(): DxGridPlaneCells {
190
191
  return {
191
192
  [toPlaneCellIndex({ col: 0, row: 0 })]: {
192
- value: '',
193
193
  accessoryHtml: tableControls.checkbox.render({
194
194
  rowIndex: 0,
195
195
  header: true,
196
196
  checked: this.model.selection.allRowsSeleted.value,
197
197
  }),
198
+ className: '!bg-gridHeader',
198
199
  readonly: true,
200
+ value: '',
199
201
  },
200
202
  };
201
203
  }
@@ -203,11 +205,12 @@ export class TablePresentation<T extends BaseTableRow = { id: string }> {
203
205
  private getNewColumnCell(): DxGridPlaneCells {
204
206
  return {
205
207
  [toPlaneCellIndex({ col: 0, row: 0 })]: {
206
- value: '',
207
208
  accessoryHtml: tableButtons.addColumn.render({
208
209
  disabled: (this.model.table.view?.target?.fields?.length ?? 0) >= VIEW_FIELD_LIMIT,
209
210
  }),
211
+ className: '!bg-gridHeader',
210
212
  readonly: true,
213
+ value: '',
211
214
  },
212
215
  };
213
216
  }
@@ -0,0 +1,131 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { computed, type Signal, signal, type ReadonlySignal } from '@preact/signals-core';
6
+ import orderBy from 'lodash.orderby';
7
+
8
+ import { getValue, FormatEnum, TypeEnum } from '@dxos/echo-schema';
9
+ import { formatForDisplay } from '@dxos/react-ui-form';
10
+ import { type PropertyType, type FieldType, type ViewProjection } from '@dxos/schema';
11
+
12
+ import { type BaseTableRow } from './table-model';
13
+ import { type TableType } from '../types';
14
+
15
+ export type SortDirection = 'asc' | 'desc';
16
+ export type SortConfig = { fieldId: string; direction: SortDirection };
17
+
18
+ /**
19
+ * Manages table sorting functionality through display and data index mapping.
20
+ *
21
+ * When a table is sorted, rows visually reorder but we need to maintain references
22
+ * to the original data positions. This is handled through two types of indices:
23
+ *
24
+ * - Display indices: The visual positions after sorting
25
+ * - Data indices: The original positions in the source data
26
+ *
27
+ * The _displayToDataIndex map maintains these relationships, enabling lookups
28
+ * between visual and source positions.
29
+ *
30
+ * Example mapping between display and data indices:
31
+ *
32
+ * Original Data: Sorted Display: Mapping: (Display → Data)
33
+ * ┌──────────────┐ ┌──────────────┐ ┌────────┐
34
+ * │ 0: "Banana" │ => │ 0: "Apple" │ => │ 0 => 1 │
35
+ * │ 1: "Apple" │ => │ 1: "Banana" │ => │ 1 => 0 │
36
+ * │ 2: "Cherry" │ => │ 2: "Cherry" │ => │ 2 => 2 │
37
+ * └──────────────┘ └──────────────┘ └────────┘
38
+ */
39
+ export class TableSorting<T extends BaseTableRow> {
40
+ private readonly _displayToDataIndex = new Map<number, number>();
41
+ private readonly _sorting = signal<SortConfig | undefined>(undefined);
42
+ private readonly _rows: Signal<T[]>;
43
+ public readonly sortedRows: ReadonlySignal<T[]>;
44
+
45
+ constructor(
46
+ rows: Signal<T[]>,
47
+ private readonly _table: TableType,
48
+ private readonly _projection: ViewProjection,
49
+ ) {
50
+ this._rows = rows;
51
+ this.sortedRows = this.initialiseSortedRows();
52
+ }
53
+
54
+ /**
55
+ * @reactive
56
+ */
57
+ public get sorting(): SortConfig | undefined {
58
+ return this._sorting.value;
59
+ }
60
+
61
+ public getDataIndex(displayIndex: number): number {
62
+ return this._displayToDataIndex.get(displayIndex) ?? displayIndex;
63
+ }
64
+
65
+ public setSort(fieldId: string, direction: SortDirection): void {
66
+ this._sorting.value = { fieldId, direction };
67
+ }
68
+
69
+ //
70
+ // Initialisation.
71
+ //
72
+
73
+ private initialiseSortedRows(): ReadonlySignal<T[]> {
74
+ return computed(() => {
75
+ this._displayToDataIndex.clear();
76
+ const sort = this._sorting.value;
77
+ if (!sort) {
78
+ return this._rows.value;
79
+ }
80
+
81
+ const field = this._table.view?.target?.fields.find((f) => f.id === sort.fieldId);
82
+ if (!field) {
83
+ return this._rows.value;
84
+ }
85
+
86
+ const { props } = this._projection.getFieldProjection(field.id);
87
+
88
+ const dataWithIndices = this._rows.value.map((item, index) => {
89
+ const sortValue = this.getSortValue(props, field, item);
90
+ const isEmpty = !(props.type === TypeEnum.Boolean) && (sortValue == null || sortValue === '');
91
+
92
+ return { item, index, value: sortValue, isEmpty };
93
+ });
94
+
95
+ const ordered = orderBy(dataWithIndices, ['isEmpty', 'value'], ['asc', sort.direction]);
96
+
97
+ ordered.forEach(({ index: dataIndex }, displayIndex) => {
98
+ if (displayIndex !== dataIndex) {
99
+ this._displayToDataIndex.set(displayIndex, dataIndex);
100
+ }
101
+ });
102
+
103
+ return ordered.map(({ item }) => item);
104
+ });
105
+ }
106
+
107
+ private getSortValue(props: PropertyType, field: FieldType, item: T) {
108
+ const { type, format } = props;
109
+ const value = getValue(item, field.path);
110
+
111
+ if (type === TypeEnum.Boolean) {
112
+ return !!value; // Coerce null/undefined to false for booleans.
113
+ }
114
+ if (type === TypeEnum.Number) {
115
+ return value;
116
+ }
117
+ if (value == null) {
118
+ return value;
119
+ }
120
+
121
+ // TODO(ZaymonFC): Should we format this returned value for display based on the referenced prop format?
122
+ // Right now we're going to be sorting based on the raw values.
123
+ // Maybe we need to recurse this function with the referenced prop format and field?
124
+ if (format === FormatEnum.Ref && field.referencePath) {
125
+ const refValue = getValue(value.target, field.referencePath);
126
+ return refValue;
127
+ }
128
+
129
+ return formatForDisplay({ type, format, value });
130
+ }
131
+ }
@@ -123,21 +123,36 @@ test.describe('Table', () => {
123
123
  await page.close();
124
124
  });
125
125
 
126
- test('pick reference', async ({ browser }) => {
126
+ test('reference > reference / create new object', async ({ browser }) => {
127
127
  const { page } = await setupPage(browser, { url: storyUrl });
128
128
  const table = new TableManager(page);
129
129
 
130
130
  await table.grid.ready();
131
131
 
132
+ // Reference existing object.
133
+ // Scroll over to the manager column.
132
134
  await table.grid.panByWheel(10000, 0);
133
135
 
134
136
  await table.grid.cell(4, 0, 'grid').click();
135
137
  await page.keyboard.press('A');
138
+ await page.keyboard.press('N');
136
139
  await page.getByRole('option', { name: 'Anita Mayer' }).click();
137
140
 
138
141
  // Assert that the value is shown in the cell.
139
142
  await expect(page.getByRole('gridcell', { name: 'Anita Mayer' })).toBeVisible();
140
143
 
144
+ // Create new object.
145
+ await table.grid.cell(4, 1, 'grid').click();
146
+ await page.keyboard.type('t');
147
+ await page.keyboard.type('e');
148
+ await page.keyboard.type('s');
149
+ await page.keyboard.type('t');
150
+ await page.getByText('Create new object', { exact: false }).click();
151
+ await page.getByTestId('save-button').click();
152
+
153
+ // Scroll to the left.
154
+ await page.getByRole('gridcell', { name: 'test' }).click();
155
+
141
156
  await page.close();
142
157
  });
143
158