@dxos/react-ui-table 0.8.3-main.7f5a14c → 0.8.3

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 (56) hide show
  1. package/dist/lib/browser/{chunk-U6LL2AVU.mjs → chunk-TL7CKQQV.mjs} +4 -3
  2. package/dist/lib/browser/chunk-TL7CKQQV.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +252 -125
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/types/index.mjs +1 -1
  7. package/dist/lib/node/{chunk-CXDGXPM6.cjs → chunk-O2VPBNS5.cjs} +6 -5
  8. package/dist/lib/node/chunk-O2VPBNS5.cjs.map +7 -0
  9. package/dist/lib/node/index.cjs +252 -127
  10. package/dist/lib/node/index.cjs.map +4 -4
  11. package/dist/lib/node/meta.json +1 -1
  12. package/dist/lib/node/types/index.cjs +3 -3
  13. package/dist/lib/node/types/index.cjs.map +1 -1
  14. package/dist/lib/node-esm/{chunk-EFSM52KQ.mjs → chunk-GBT6GD4G.mjs} +4 -3
  15. package/dist/lib/node-esm/chunk-GBT6GD4G.mjs.map +7 -0
  16. package/dist/lib/node-esm/index.mjs +252 -125
  17. package/dist/lib/node-esm/index.mjs.map +4 -4
  18. package/dist/lib/node-esm/meta.json +1 -1
  19. package/dist/lib/node-esm/types/index.mjs +1 -1
  20. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Table/Table.d.ts +2 -0
  22. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/Table/relations.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +7 -0
  26. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -0
  27. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  28. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/testing.d.ts +2 -2
  30. package/dist/types/src/components/testing.d.ts.map +1 -1
  31. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  32. package/dist/types/src/model/table-model.d.ts +7 -0
  33. package/dist/types/src/model/table-model.d.ts.map +1 -1
  34. package/dist/types/src/types/table.d.ts +5 -4
  35. package/dist/types/src/types/table.d.ts.map +1 -1
  36. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  37. package/package.json +34 -34
  38. package/src/components/Table/DynamicTable.stories.tsx +3 -2
  39. package/src/components/Table/Table.stories.tsx +15 -7
  40. package/src/components/Table/Table.tsx +13 -3
  41. package/src/components/Table/relations.stories.tsx +15 -10
  42. package/src/components/TableCellEditor/CellValidationMessage.tsx +38 -0
  43. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +2 -1
  44. package/src/components/TableCellEditor/TableCellEditor.tsx +54 -22
  45. package/src/components/testing.ts +5 -4
  46. package/src/hooks/useTableModel.ts +8 -1
  47. package/src/model/table-model.test.ts +3 -2
  48. package/src/model/table-model.ts +88 -39
  49. package/src/model/table-presentation.test.ts +3 -2
  50. package/src/model/table-presentation.ts +1 -1
  51. package/src/types/table.ts +3 -2
  52. package/src/util/dynamic-table.ts +2 -2
  53. package/src/util/table.ts +11 -10
  54. package/dist/lib/browser/chunk-U6LL2AVU.mjs.map +0 -7
  55. package/dist/lib/node/chunk-CXDGXPM6.cjs.map +0 -7
  56. package/dist/lib/node-esm/chunk-EFSM52KQ.mjs.map +0 -7
@@ -8,7 +8,7 @@ import { type StoryObj, type Meta } from '@storybook/react';
8
8
  import { Schema } from 'effect';
9
9
  import React, { useCallback, useMemo, useRef } from 'react';
10
10
 
11
- import { Type } from '@dxos/echo';
11
+ import { Obj, Type } from '@dxos/echo';
12
12
  import { FormatEnum, isMutable, toJsonSchema, EchoObject, GeneratorAnnotation } from '@dxos/echo-schema';
13
13
  import { invariant } from '@dxos/invariant';
14
14
  import { useGlobalFilteredObjects } from '@dxos/plugin-search';
@@ -125,6 +125,7 @@ const useTestTableModel = () => {
125
125
  model,
126
126
  presentation,
127
127
  space,
128
+ client,
128
129
  handleInsertRow,
129
130
  handleSaveView,
130
131
  handleDeleteRows,
@@ -166,7 +167,7 @@ const StoryViewEditor = () => {
166
167
  //
167
168
 
168
169
  const DefaultStory = () => {
169
- const { schema, table, tableRef, model, presentation, handleInsertRow, handleSaveView } = useTestTableModel();
170
+ const { schema, table, tableRef, model, presentation, client, handleInsertRow, handleSaveView } = useTestTableModel();
170
171
 
171
172
  if (!schema || !table) {
172
173
  return <div />;
@@ -177,7 +178,14 @@ const DefaultStory = () => {
177
178
  <div className='grid grid-rows-[min-content_1fr] min-bs-0 overflow-hidden'>
178
179
  <TableToolbar classNames='border-be border-subduedSeparator' onAdd={handleInsertRow} onSave={handleSaveView} />
179
180
  <Table.Root>
180
- <Table.Main ref={tableRef} model={model} presentation={presentation} schema={schema} ignoreAttention />
181
+ <Table.Main
182
+ ref={tableRef}
183
+ model={model}
184
+ presentation={presentation}
185
+ schema={schema}
186
+ client={client}
187
+ ignoreAttention
188
+ />
181
189
  </Table.Root>
182
190
  </div>
183
191
  <div className='flex flex-col h-full border-l border-separator overflow-y-auto'>
@@ -208,7 +216,7 @@ const meta: Meta<StoryProps> = {
208
216
  createIdentity: true,
209
217
  createSpace: true,
210
218
  onSpaceCreated: async ({ client, space }) => {
211
- const table = space.db.add(live(TableType, {}));
219
+ const table = space.db.add(Obj.make(TableType, {}));
212
220
  const schema = await initializeTable({ client, space, table, initialRow: false });
213
221
  Array.from({ length: 10 }).map(() => {
214
222
  return space.db.add(
@@ -237,7 +245,7 @@ export const StaticSchema: StoryObj = {
237
245
  createIdentity: true,
238
246
  createSpace: true,
239
247
  onSpaceCreated: async ({ client, space }) => {
240
- const table = space.db.add(live(TableType, {}));
248
+ const table = space.db.add(Obj.make(TableType, {}));
241
249
  await initializeTable({ client, space, table, typename: Testing.Organization.typename });
242
250
 
243
251
  const factory = createObjectFactory(space.db, faker as any);
@@ -279,7 +287,7 @@ export const ArrayOfObjects: StoryObj = {
279
287
  createIdentity: true,
280
288
  createSpace: true,
281
289
  onSpaceCreated: async ({ client, space }) => {
282
- const table = space.db.add(live(TableType, {}));
290
+ const table = space.db.add(Obj.make(TableType, {}));
283
291
  await initializeTable({ client, space, table, typename: ContactWithArrayOfEmails.typename });
284
292
 
285
293
  const factory = createObjectFactory(space.db, faker as any);
@@ -332,7 +340,7 @@ export const Tags: Meta<StoryProps> = {
332
340
  const [storedSchema] = await space.db.schemaRegistry.register([schema]);
333
341
 
334
342
  // Initialize table.
335
- const table = space.db.add(live(TableType, {}));
343
+ const table = space.db.add(Obj.make(TableType, {}));
336
344
  await initializeTable({ client, space, table, initialRow: false, typename });
337
345
 
338
346
  // Populate.
@@ -15,6 +15,7 @@ import React, {
15
15
  useEffect,
16
16
  } from 'react';
17
17
 
18
+ import { type Client } from '@dxos/client';
18
19
  import { getValue } from '@dxos/echo-schema';
19
20
  import { invariant } from '@dxos/invariant';
20
21
  import { Filter } from '@dxos/react-client/echo';
@@ -77,13 +78,14 @@ export type TableMainProps = {
77
78
  model?: TableModel;
78
79
  presentation?: TablePresentation;
79
80
  schema?: Schema.AnyNoContext;
81
+ client?: Client;
80
82
  // TODO(burdon): Rename since attention isn't a useful concept here? Standardize across other components. Pass property into useAttention.
81
83
  ignoreAttention?: boolean;
82
84
  onRowClick?: (row: any) => void;
83
85
  };
84
86
 
85
87
  const TableMain = forwardRef<TableController, TableMainProps>(
86
- ({ model, presentation, ignoreAttention, schema, onRowClick }, forwardedRef) => {
88
+ ({ model, presentation, ignoreAttention, schema, client, onRowClick }, forwardedRef) => {
87
89
  const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
88
90
  const { hasAttention } = useAttention(model?.id ?? 'table');
89
91
  const { t } = useTranslation(translationKey);
@@ -307,7 +309,15 @@ const TableMain = forwardRef<TableController, TableMainProps>(
307
309
  if (model && props.referenceSchema && field.referencePath) {
308
310
  const space = model.space;
309
311
  invariant(space);
310
- const schema = space.db.schemaRegistry.getSchema(props.referenceSchema);
312
+
313
+ let schema;
314
+ if (client) {
315
+ schema = client.graph.schemaRegistry.getSchema(props.referenceSchema);
316
+ }
317
+ if (!schema) {
318
+ schema = space.db.schemaRegistry.getSchema(props.referenceSchema);
319
+ }
320
+
311
321
  if (schema) {
312
322
  const { objects } = await space.db.query(Filter.type(schema)).run();
313
323
  const options = objects
@@ -336,7 +346,7 @@ const TableMain = forwardRef<TableController, TableMainProps>(
336
346
 
337
347
  return [];
338
348
  },
339
- [model, t],
349
+ [model, client, t],
340
350
  );
341
351
 
342
352
  if (!model || !modals) {
@@ -5,15 +5,16 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import { type StoryObj, type Meta } from '@storybook/react';
8
- import { type Schema } from 'effect';
9
8
  import { SchemaAST } from 'effect';
10
9
  import React, { useEffect, useMemo } from 'react';
11
10
 
12
- import { type BaseObject, getSchemaTypename, type HasId, toJsonSchema } from '@dxos/echo-schema';
11
+ import { Obj, type Type } from '@dxos/echo';
12
+ import { getSchemaTypename, toJsonSchema } from '@dxos/echo-schema';
13
13
  import { getAnnotation } from '@dxos/effect';
14
14
  import { invariant } from '@dxos/invariant';
15
15
  import { faker } from '@dxos/random';
16
- import { live, makeRef } from '@dxos/react-client/echo';
16
+ import { useClient } from '@dxos/react-client';
17
+ import { makeRef } from '@dxos/react-client/echo';
17
18
  import { useClientProvider, withClientProvider } from '@dxos/react-client/testing';
18
19
  import { DataType, createView, ViewProjection, ViewType } from '@dxos/schema';
19
20
  import { createAsyncGenerator, type ValueGenerator } from '@dxos/schema/testing';
@@ -32,26 +33,29 @@ const generator: ValueGenerator = faker as any;
32
33
  // TODO(burdon): Mutable and immutable views.
33
34
  // TODO(burdon): Reconcile schemas types and utils (see API PR).
34
35
  // TODO(burdon): Base type for T (with id); see ECHO API PR?
35
- const useTestModel = <T extends BaseObject & HasId>(schema: Schema.Schema<T>, count: number) => {
36
+ const useTestModel = <S extends Type.Obj.Any>(schema: S, count: number) => {
36
37
  const { space } = useClientProvider();
37
38
 
38
39
  const jsonSchema = useMemo(() => toJsonSchema(schema), [schema]);
39
40
  const table = useMemo(() => {
41
+ if (!space) {
42
+ return undefined;
43
+ }
40
44
  const typename = getSchemaTypename(schema);
41
45
  invariant(typename);
42
46
  const name = getAnnotation<string>(SchemaAST.TitleAnnotationId)(schema.ast) ?? typename;
43
47
  const view = createView({ name, typename, jsonSchema });
44
- return live(TableType, { view: makeRef(view) });
45
- }, [schema]);
48
+ return space.db.add(Obj.make(TableType, { view: makeRef(view) }));
49
+ }, [schema, space, jsonSchema]);
46
50
 
47
51
  const projection = useMemo(() => {
48
- if (!table.view?.target) {
52
+ if (!table?.view?.target) {
49
53
  return undefined;
50
54
  }
51
55
 
52
56
  // TODO(burdon): Just pass in view? Reuse same jsonSchema instance? View determines if mutable, etc.
53
57
  return new ViewProjection(jsonSchema, table.view.target);
54
- }, [schema, table]);
58
+ }, [jsonSchema, table]);
55
59
 
56
60
  const features = useMemo<TableFeatures>(
57
61
  () => ({ schemaEditable: false, dataEditable: true, selection: { enabled: false } }),
@@ -82,16 +86,17 @@ const useTestModel = <T extends BaseObject & HasId>(schema: Schema.Schema<T>, co
82
86
  };
83
87
 
84
88
  const DefaultStory = () => {
89
+ const client = useClient();
85
90
  const { model: orgModel, presentation: orgPresentation } = useTestModel(DataType.Organization, 50);
86
91
  const { model: contactModel, presentation: contactPresentation } = useTestModel(DataType.Person, 50);
87
92
 
88
93
  return (
89
94
  <div className='is-full bs-full grid grid-cols-2 divide-x divide-separator'>
90
95
  <Table.Root>
91
- <Table.Main model={orgModel} presentation={orgPresentation} ignoreAttention />
96
+ <Table.Main model={orgModel} presentation={orgPresentation} client={client} ignoreAttention />
92
97
  </Table.Root>
93
98
  <Table.Root>
94
- <Table.Main model={contactModel} presentation={contactPresentation} ignoreAttention />
99
+ <Table.Main model={contactModel} presentation={contactPresentation} client={client} ignoreAttention />
95
100
  </Table.Root>
96
101
  </div>
97
102
  );
@@ -0,0 +1,38 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { useGridContext, type GridScopedProps } from '@dxos/react-ui-grid';
8
+
9
+ export type CellValidationMessageProps = {
10
+ validationError: string | null;
11
+ };
12
+
13
+ export const CellValidationMessage = ({
14
+ validationError,
15
+ __gridScope,
16
+ }: GridScopedProps<CellValidationMessageProps>) => {
17
+ const { editing, editBox: box } = useGridContext('GridSheetCellEditor', __gridScope);
18
+
19
+ if (!editing || !validationError) {
20
+ return null;
21
+ }
22
+
23
+ return (
24
+ <div
25
+ role='none'
26
+ className='absolute bg-errorSurface text-errorSurfaceText rounded-bs-sm text-xs p-1'
27
+ style={{
28
+ ...{ '--dx-gridCellWidth': `${box?.inlineSize ?? 200}px` },
29
+ zIndex: 10,
30
+ insetBlockEnd: `calc(100% - ${box.insetBlockStart}px)`,
31
+ insetInlineStart: box.insetInlineStart,
32
+ inlineSize: box.inlineSize,
33
+ }}
34
+ >
35
+ {validationError}
36
+ </div>
37
+ );
38
+ };
@@ -7,6 +7,7 @@ import '@dxos-theme';
7
7
  import { type Meta, type StoryObj } from '@storybook/react';
8
8
  import React, { useEffect, useMemo, useState } from 'react';
9
9
 
10
+ import { Obj } from '@dxos/echo';
10
11
  import { type EchoSchema, isMutable } from '@dxos/echo-schema';
11
12
  import { invariant } from '@dxos/invariant';
12
13
  import { faker } from '@dxos/random';
@@ -96,7 +97,7 @@ const meta: Meta<StoryProps> = {
96
97
  createIdentity: true,
97
98
  createSpace: true,
98
99
  onSpaceCreated: async ({ client, space }) => {
99
- const table = space.db.add(live(TableType, {}));
100
+ const table = space.db.add(Obj.make(TableType, {}));
100
101
  const schema = await initializeTable({ client, space, table });
101
102
  Array.from({ length: 10 }).forEach(() => {
102
103
  space.db.add(
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { type Completion } from '@codemirror/autocomplete';
6
6
  import { type Schema } from 'effect/Schema';
7
- import React, { useCallback, useMemo, useRef } from 'react';
7
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
8
8
 
9
9
  import { FormatEnum, TypeEnum } from '@dxos/echo-schema';
10
10
  import { invariant } from '@dxos/invariant';
@@ -27,6 +27,7 @@ import {
27
27
  import { tagPickerExtension, createLinks } from '@dxos/react-ui-tag-picker';
28
28
  import { type FieldProjection } from '@dxos/schema';
29
29
 
30
+ import { CellValidationMessage } from './CellValidationMessage';
30
31
  import { FormCellEditor } from './FormCellEditor';
31
32
  import { completion } from './extension';
32
33
  import { type TableModel, type ModalController } from '../../model';
@@ -102,6 +103,7 @@ export const TableCellEditor = ({
102
103
  const { id: gridId, editing, setEditing } = useGridContext('TableCellEditor', __gridScope);
103
104
  const suppressNextBlur = useRef(false);
104
105
  const { themeMode } = useThemeContext();
106
+ const [_validationError, setValidationError] = useState<string | null>(null);
105
107
 
106
108
  const fieldProjection = useMemo<FieldProjection | undefined>(() => {
107
109
  if (!model || !editing) {
@@ -116,51 +118,76 @@ export const TableCellEditor = ({
116
118
  }, [model, editing]);
117
119
 
118
120
  const handleEnter = useCallback(
119
- (value: any) => {
121
+ async (value: any) => {
120
122
  if (!model || !editing) {
121
123
  return;
122
124
  }
123
125
 
124
126
  const cell = parseCellIndex(editing.index);
125
- model.setCellData(cell, value);
126
- onEnter?.(cell);
127
- onFocus?.();
128
- setEditing(null);
127
+
128
+ // Validate the value
129
+ const result = await model.validateCellData(cell, value);
130
+
131
+ if (result.valid) {
132
+ setValidationError(null);
133
+ model.setCellData(cell, value);
134
+ onEnter?.(cell);
135
+ onFocus?.();
136
+ setEditing(null);
137
+ } else {
138
+ setValidationError(result.error);
139
+ // Editor stays open on validation failure
140
+ }
129
141
  },
130
142
  [model, editing, onEnter, onFocus, setEditing],
131
143
  );
132
144
 
133
145
  const handleBlur = useCallback<EditorBlurHandler>(
134
- (value) => {
146
+ async (value) => {
135
147
  if (!model || !editing) {
136
148
  return;
137
149
  }
138
150
  if (suppressNextBlur.current) {
139
151
  suppressNextBlur.current = false;
140
- return;
141
152
  }
142
153
 
143
- const cell = parseCellIndex(editing.index);
144
- if (value !== undefined) {
145
- model.setCellData(cell, value);
146
- }
154
+ // Don't save on blur - let handleClose handle validation and saving
147
155
  },
148
156
  [model, editing],
149
157
  );
150
158
 
151
159
  const handleClose = useCallback<EditorKeyOrBlurHandler>(
152
- (value, event) => {
160
+ async (value, event) => {
153
161
  if (!model || !editing || !fieldProjection) {
154
162
  return;
155
163
  }
156
164
 
157
165
  const cell = parseCellIndex(editing.index);
158
- onEnter?.(cell);
159
- if (event && onFocus) {
160
- onFocus(determineNavigationAxis(event), determineNavigationDelta(event));
166
+
167
+ if (value !== undefined) {
168
+ // Pre-commit validation check.
169
+ const result = await model.validateCellData(cell, value);
170
+
171
+ if (result.valid) {
172
+ setValidationError(null);
173
+ model.setCellData(cell, value);
174
+ setEditing(null);
175
+ onEnter?.(cell);
176
+ if (event && onFocus) {
177
+ onFocus(determineNavigationAxis(event), determineNavigationDelta(event));
178
+ }
179
+ } else {
180
+ setValidationError(result.error);
181
+ }
182
+ } else {
183
+ setValidationError(null);
184
+ setEditing(null);
185
+ if (event && onFocus) {
186
+ onFocus(determineNavigationAxis(event), determineNavigationDelta(event));
187
+ }
161
188
  }
162
189
  },
163
- [model, editing, onFocus, onEnter, fieldProjection],
190
+ [model, editing, onFocus, onEnter, fieldProjection, setEditing],
164
191
  );
165
192
 
166
193
  const extension = useMemo(() => {
@@ -209,9 +236,9 @@ export const TableCellEditor = ({
209
236
  return;
210
237
  }
211
238
  if (mode === 'single-select') {
212
- handleEnter(ids[0]);
239
+ void handleEnter(ids[0]);
213
240
  } else {
214
- handleEnter(ids);
241
+ void handleEnter(ids);
215
242
  }
216
243
  }
217
244
  },
@@ -238,12 +265,12 @@ export const TableCellEditor = ({
238
265
  [field.referencePath!]: data.text,
239
266
  },
240
267
  (data) => {
241
- handleEnter(data);
268
+ void handleEnter(data);
242
269
  },
243
270
  );
244
271
  }
245
272
  } else {
246
- handleEnter(data);
273
+ void handleEnter(data);
247
274
  }
248
275
  }
249
276
  },
@@ -312,7 +339,12 @@ export const TableCellEditor = ({
312
339
  }
313
340
  }, [model, editing]);
314
341
 
315
- return <GridCellEditor extension={extension} getCellContent={getCellContent} onBlur={handleBlur} />;
342
+ return (
343
+ <>
344
+ <CellValidationMessage validationError={_validationError} __gridScope={__gridScope} />
345
+ <GridCellEditor extension={extension} getCellContent={getCellContent} onBlur={handleBlur} />
346
+ </>
347
+ );
316
348
  };
317
349
 
318
350
  const determineNavigationAxis = ({ key }: EditorKeyEvent): 'col' | 'row' | undefined => {
@@ -5,9 +5,10 @@
5
5
  import { Schema } from 'effect';
6
6
  import { useEffect } from 'react';
7
7
 
8
- import { setValue, toJsonSchema, TypeEnum, TypedObject, FormatEnum, ObjectId } from '@dxos/echo-schema';
8
+ import { Obj, Ref } from '@dxos/echo';
9
+ import { FormatEnum, ObjectId, setValue, toJsonSchema, TypedObject, TypeEnum } from '@dxos/echo-schema';
9
10
  import { faker } from '@dxos/random';
10
- import { live, makeRef } from '@dxos/react-client/echo';
11
+ import { live } from '@dxos/react-client/echo';
11
12
  import { createView, type ViewProjection } from '@dxos/schema';
12
13
 
13
14
  import { TableType } from '../types';
@@ -21,8 +22,8 @@ export const TestSchema = TypedObject({ typename: 'example.com/type/Test', versi
21
22
  });
22
23
 
23
24
  export const createTable = (schema = TestSchema) => {
24
- return live(TableType, {
25
- view: makeRef(
25
+ return Obj.make(TableType, {
26
+ view: Ref.make(
26
27
  createView({
27
28
  name: 'Test',
28
29
  typename: schema.typename,
@@ -3,6 +3,7 @@
3
3
  //
4
4
 
5
5
  import { effect } from '@preact/signals-core';
6
+ import orderBy from 'lodash.orderby';
6
7
  import { useEffect, useState } from 'react';
7
8
 
8
9
  import { fullyQualifiedId, getSpace, type Live } from '@dxos/react-client/echo';
@@ -66,7 +67,13 @@ export const useTableModel = <T extends TableRow = TableRow>({
66
67
  // Update data.
67
68
  useEffect(() => {
68
69
  if (rows) {
69
- model?.setRows(rows);
70
+ // TODO(ZaymonFC): Remove this workaround once unstable query ordering issue is resolved
71
+ /*
72
+ * Sort all objects by string id field as a temporary workaround for query ordering issues
73
+ * Reference: https://github.com/dxos/dxos/pull/9409
74
+ */
75
+ const sortedRows = orderBy(rows, [(row) => String(row.id)], ['asc']);
76
+ model?.setRows(sortedRows);
70
77
  }
71
78
  }, [model, rows]);
72
79
 
@@ -6,11 +6,12 @@ import { computed } from '@preact/signals-core';
6
6
  import { Schema } from 'effect';
7
7
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
8
 
9
+ import { Obj, Ref } from '@dxos/echo';
9
10
  import { TypedObject } from '@dxos/echo-schema';
10
11
  import { updateCounter } from '@dxos/echo-schema/testing';
11
12
  import { registerSignalsRuntime } from '@dxos/echo-signals';
12
13
  import { createEchoSchema } from '@dxos/live-object/testing';
13
- import { live, makeRef } from '@dxos/react-client/echo';
14
+ import { live } from '@dxos/react-client/echo';
14
15
  import { createView, ViewProjection } from '@dxos/schema';
15
16
 
16
17
  import { TableModel, type TableModelProps } from './table-model';
@@ -108,6 +109,6 @@ const createTableModel = (props: Partial<TableModelProps> = {}): TableModel => {
108
109
  const schema = createEchoSchema(Test);
109
110
  const view = createView({ name: 'Test', typename: schema.typename, jsonSchema: schema.jsonSchema });
110
111
  const projection = new ViewProjection(schema.jsonSchema, view);
111
- const table = live(TableType, { view: makeRef(view) });
112
+ const table = Obj.make(TableType, { view: Ref.make(view) });
112
113
  return new TableModel({ id: table.id, space: undefined, view, projection, ...props });
113
114
  };
@@ -6,7 +6,15 @@ import { computed, effect, signal, type ReadonlySignal } from '@preact/signals-c
6
6
 
7
7
  import { type Space } from '@dxos/client/echo';
8
8
  import { Resource } from '@dxos/context';
9
- import { type FieldSortType, FormatEnum, getValue, setValue, type JsonProp } from '@dxos/echo-schema';
9
+ import {
10
+ type FieldSortType,
11
+ FormatEnum,
12
+ getValue,
13
+ setValue,
14
+ type JsonProp,
15
+ getSnapshot,
16
+ getSchema,
17
+ } from '@dxos/echo-schema';
10
18
  import { invariant } from '@dxos/invariant';
11
19
  import { isLiveObject, makeRef } from '@dxos/live-object';
12
20
  import { formatForEditing, parseValue } from '@dxos/react-ui-form';
@@ -16,13 +24,16 @@ import {
16
24
  type DxGridPlanePosition,
17
25
  type DxGridPosition,
18
26
  } from '@dxos/react-ui-grid';
19
- import { type ViewType, type ViewProjection } from '@dxos/schema';
27
+ import { type ViewType, type ViewProjection, type PropertyType, validateSchema } from '@dxos/schema';
20
28
 
21
29
  import { type SelectionMode, SelectionModel } from './selection-model';
22
30
  import { TableSorting } from './table-sorting';
23
31
  import { touch } from '../util';
24
32
  import { extractTagIds } from '../util/tag';
25
33
 
34
+ // TODO(ZaymonFC): Use a common type?
35
+ export type ValidationResult = { valid: true } | { valid: false; error: string };
36
+
26
37
  // TODO(burdon): Use schema types.
27
38
  export type TableRow = Record<JsonProp, any> & { id: string };
28
39
 
@@ -330,54 +341,58 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
330
341
  }
331
342
  };
332
343
 
333
- public setCellData = ({ col, row }: DxGridPlanePosition, value: any): void => {
344
+ public validateCellData = async ({ col, row }: DxGridPlanePosition, value: any): Promise<ValidationResult> => {
334
345
  const rowIdx = this._sorting.getDataIndex(row);
335
346
  const fields = this._view?.fields ?? [];
336
347
  if (col < 0 || col >= fields.length) {
337
- return;
348
+ return { valid: false, error: 'Invalid column index' };
338
349
  }
339
350
 
340
351
  const field = fields[col];
341
352
  const { props } = this._projection.getFieldProjection(field.id);
342
- switch (props.format) {
343
- case FormatEnum.Ref: {
344
- // TODO(ZaymonFC): This get's called an additional time by the cell editor onBlur, but with the cell editors
345
- // plain string value. Maybe onBlur should be called with the actual value?
346
- if (isLiveObject(value)) {
347
- setValue(this._rows.value[rowIdx], field.path, makeRef(value));
348
- }
349
- break;
350
- }
351
353
 
352
- case FormatEnum.SingleSelect: {
353
- const ids = extractTagIds(value);
354
- if (ids && ids.length > 0) {
355
- setValue(this._rows.value[rowIdx], field.path, ids[0]);
356
- }
357
- break;
358
- }
354
+ const currentRow = this._rows.value[rowIdx];
355
+ invariant(currentRow, 'Invalid row index');
359
356
 
360
- case FormatEnum.MultiSelect: {
361
- const ids = extractTagIds(value);
362
- if (ids) {
363
- setValue(this._rows.value[rowIdx], field.path, ids);
364
- }
365
- break;
366
- }
357
+ // Get a snapshot of the current object.
358
+ const snapshot = getSnapshot(currentRow);
359
+ const transformedValue = editorTextToCellValue(props, value);
367
360
 
368
- default: {
369
- setValue(
370
- this._rows.value[rowIdx],
371
- field.path,
372
- parseValue({
373
- type: props.type,
374
- format: props.format,
375
- value,
376
- }),
377
- );
378
- break;
379
- }
361
+ // Set the proposed value.
362
+ setValue(snapshot, field.path, transformedValue);
363
+
364
+ const schema = getSchema(currentRow);
365
+ invariant(schema);
366
+
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
+ }
373
+
374
+ return { valid: true };
375
+ };
376
+
377
+ public setCellData = ({ col, row }: DxGridPlanePosition, value: any): void => {
378
+ const rowIdx = this._sorting.getDataIndex(row);
379
+ const fields = this._view?.fields ?? [];
380
+ if (col < 0 || col >= fields.length) {
381
+ return;
382
+ }
383
+
384
+ const field = fields[col];
385
+ const { props } = this._projection.getFieldProjection(field.id);
386
+ const transformedValue = editorTextToCellValue(props, value);
387
+
388
+ // 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?
392
+ return;
380
393
  }
394
+
395
+ setValue(this._rows.value[rowIdx], field.path, transformedValue);
381
396
  };
382
397
 
383
398
  /**
@@ -446,3 +461,37 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
446
461
  this._sorting.save();
447
462
  }
448
463
  }
464
+
465
+ const editorTextToCellValue = (props: PropertyType, value: any): any => {
466
+ switch (props.format) {
467
+ case FormatEnum.Ref: {
468
+ if (isLiveObject(value)) {
469
+ return makeRef(value);
470
+ } else {
471
+ return value;
472
+ }
473
+ }
474
+
475
+ case FormatEnum.SingleSelect: {
476
+ const ids = extractTagIds(value);
477
+ if (ids && ids.length > 0) {
478
+ return ids[0];
479
+ } else {
480
+ return value;
481
+ }
482
+ }
483
+
484
+ case FormatEnum.MultiSelect: {
485
+ const ids = extractTagIds(value);
486
+ return ids || value;
487
+ }
488
+
489
+ default: {
490
+ return parseValue({
491
+ type: props.type,
492
+ format: props.format,
493
+ value,
494
+ });
495
+ }
496
+ }
497
+ };
@@ -5,8 +5,9 @@
5
5
  import { Schema } from 'effect';
6
6
  import { describe, expect, it, beforeEach } from 'vitest';
7
7
 
8
+ import { Obj, Ref } from '@dxos/echo';
8
9
  import { TypedObject } from '@dxos/echo-schema';
9
- import { live, makeRef } from '@dxos/live-object';
10
+ import { live } from '@dxos/live-object';
10
11
  import { createEchoSchema } from '@dxos/live-object/testing';
11
12
  import { createView, ViewProjection } from '@dxos/schema';
12
13
 
@@ -98,6 +99,6 @@ const createTableModel = (props: Partial<TableModelProps> = {}): TableModel => {
98
99
  const schema = createEchoSchema(Test);
99
100
  const view = createView({ name: 'Test', typename: schema.typename, jsonSchema: schema.jsonSchema });
100
101
  const projection = new ViewProjection(schema.jsonSchema, view);
101
- const table = live(TableType, { view: makeRef(view) });
102
+ const table = Obj.make(TableType, { view: Ref.make(view) });
102
103
  return new TableModel({ id: table.id, view, projection, ...props });
103
104
  };