@dxos/react-ui-table 0.8.4-main.c1de068 → 0.8.4-main.c4373fc

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 (130) hide show
  1. package/dist/lib/browser/chunk-4K7JPALT.mjs +66 -0
  2. package/dist/lib/browser/chunk-4K7JPALT.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-5XLSHLOG.mjs → chunk-OZTXLZGM.mjs} +544 -491
  4. package/dist/lib/browser/chunk-OZTXLZGM.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +590 -675
  6. package/dist/lib/browser/index.mjs.map +4 -4
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +13 -8
  9. package/dist/lib/browser/testing/index.mjs.map +3 -3
  10. package/dist/lib/browser/types/index.mjs +3 -3
  11. package/dist/lib/node-esm/chunk-MK6OVUF6.mjs +67 -0
  12. package/dist/lib/node-esm/chunk-MK6OVUF6.mjs.map +7 -0
  13. package/dist/lib/node-esm/{chunk-Y66YLSTC.mjs → chunk-S56Z4V4X.mjs} +544 -491
  14. package/dist/lib/node-esm/chunk-S56Z4V4X.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +590 -675
  16. package/dist/lib/node-esm/index.mjs.map +4 -4
  17. package/dist/lib/node-esm/meta.json +1 -1
  18. package/dist/lib/node-esm/testing/index.mjs +13 -8
  19. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  20. package/dist/lib/node-esm/types/index.mjs +3 -3
  21. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts +1 -1
  22. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/ColumnSettings.d.ts +1 -1
  24. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/DynamicTable.d.ts +2 -2
  26. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +51 -3
  28. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Table/Relations.stories.d.ts +108 -0
  30. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Table/RowActionsMenu.d.ts +1 -1
  32. package/dist/types/src/components/Table/RowActionsMenu.d.ts.map +1 -1
  33. package/dist/types/src/components/Table/Table.d.ts +8 -4
  34. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  35. package/dist/types/src/components/Table/Table.stories.d.ts +42 -4
  36. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  37. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -1
  38. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +12 -6
  39. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  40. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +12 -17
  41. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  42. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +39 -4
  43. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  44. package/dist/types/src/components/TableCellEditor/index.d.ts +1 -0
  45. package/dist/types/src/components/TableCellEditor/index.d.ts.map +1 -1
  46. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts.map +1 -1
  47. package/dist/types/src/components/testing.d.ts +3 -3
  48. package/dist/types/src/components/testing.d.ts.map +1 -1
  49. package/dist/types/src/hooks/useAddRow.d.ts +3 -2
  50. package/dist/types/src/hooks/useAddRow.d.ts.map +1 -1
  51. package/dist/types/src/hooks/useTableModel.d.ts +2 -2
  52. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  53. package/dist/types/src/model/modal-controller.d.ts +0 -7
  54. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  55. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  56. package/dist/types/src/model/table-model.d.ts +9 -7
  57. package/dist/types/src/model/table-model.d.ts.map +1 -1
  58. package/dist/types/src/model/table-presentation.d.ts +2 -1
  59. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  60. package/dist/types/src/model/table-sorting.d.ts +2 -3
  61. package/dist/types/src/model/table-sorting.d.ts.map +1 -1
  62. package/dist/types/src/testing/useTestTableModel.d.ts +3 -22
  63. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -1
  64. package/dist/types/src/translations.d.ts +2 -2
  65. package/dist/types/src/types/index.d.ts +1 -1
  66. package/dist/types/src/types/index.d.ts.map +1 -1
  67. package/dist/types/src/types/schema.d.ts +25 -5
  68. package/dist/types/src/types/schema.d.ts.map +1 -1
  69. package/dist/types/src/util/dynamic-table.d.ts +4 -5
  70. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  71. package/dist/types/src/util/index.d.ts +0 -1
  72. package/dist/types/src/util/index.d.ts.map +1 -1
  73. package/dist/types/src/util/schema.d.ts +1 -1
  74. package/dist/types/src/util/schema.d.ts.map +1 -1
  75. package/dist/types/src/util/table-buttons.d.ts.map +1 -1
  76. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  77. package/dist/types/tsconfig.tsbuildinfo +1 -1
  78. package/package.json +55 -50
  79. package/src/components/Table/ColumnActionsMenu.tsx +1 -1
  80. package/src/components/Table/ColumnSettings.tsx +1 -1
  81. package/src/components/Table/DynamicTable.stories.tsx +15 -11
  82. package/src/components/Table/DynamicTable.tsx +8 -10
  83. package/src/components/Table/Relations.stories.tsx +248 -0
  84. package/src/components/Table/RowActionsMenu.tsx +3 -3
  85. package/src/components/Table/Table.stories.tsx +66 -54
  86. package/src/components/Table/Table.tsx +147 -117
  87. package/src/components/TableCellEditor/CellValidationMessage.tsx +1 -1
  88. package/src/components/TableCellEditor/FormCellEditor.tsx +153 -42
  89. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +24 -64
  90. package/src/components/TableCellEditor/TableCellEditor.tsx +74 -224
  91. package/src/components/TableCellEditor/index.ts +1 -0
  92. package/src/components/TableToolbar/TableToolbar.tsx +2 -2
  93. package/src/components/testing.ts +2 -2
  94. package/src/hooks/useAddRow.ts +7 -5
  95. package/src/hooks/useTableModel.ts +2 -2
  96. package/src/model/modal-controller.ts +1 -29
  97. package/src/model/selection-model.ts +3 -2
  98. package/src/model/table-model.test.ts +19 -10
  99. package/src/model/table-model.ts +37 -25
  100. package/src/model/table-presentation.test.ts +23 -21
  101. package/src/model/table-presentation.ts +74 -33
  102. package/src/model/table-sorting.ts +16 -7
  103. package/src/playwright/smoke.spec.ts +129 -37
  104. package/src/testing/useTestTableModel.ts +11 -7
  105. package/src/translations.ts +2 -2
  106. package/src/types/index.ts +1 -1
  107. package/src/types/schema.ts +63 -6
  108. package/src/util/dynamic-table.ts +13 -13
  109. package/src/util/index.ts +0 -1
  110. package/src/util/schema.ts +4 -4
  111. package/src/util/table-buttons.ts +4 -3
  112. package/src/util/table-controls.ts +4 -1
  113. package/dist/lib/browser/chunk-5XLSHLOG.mjs.map +0 -7
  114. package/dist/lib/browser/chunk-L2LSB6KV.mjs +0 -18
  115. package/dist/lib/browser/chunk-L2LSB6KV.mjs.map +0 -7
  116. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs +0 -20
  117. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs.map +0 -7
  118. package/dist/lib/node-esm/chunk-Y66YLSTC.mjs.map +0 -7
  119. package/dist/types/src/components/Table/CreateRefPanel.d.ts +0 -9
  120. package/dist/types/src/components/Table/CreateRefPanel.d.ts.map +0 -1
  121. package/dist/types/src/components/Table/relations.stories.d.ts +0 -9
  122. package/dist/types/src/components/Table/relations.stories.d.ts.map +0 -1
  123. package/dist/types/src/components/TableCellEditor/extension.d.ts +0 -13
  124. package/dist/types/src/components/TableCellEditor/extension.d.ts.map +0 -1
  125. package/dist/types/src/util/table.d.ts +0 -11
  126. package/dist/types/src/util/table.d.ts.map +0 -1
  127. package/src/components/Table/CreateRefPanel.tsx +0 -86
  128. package/src/components/Table/relations.stories.tsx +0 -116
  129. package/src/components/TableCellEditor/extension.ts +0 -61
  130. package/src/util/table.ts +0 -49
@@ -2,38 +2,38 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
- import { type StoryObj, type Meta } from '@storybook/react-vite';
8
- import { Schema } from 'effect';
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
9
7
  import React, { useCallback } from 'react';
10
8
 
11
- import { Obj, Type } from '@dxos/echo';
9
+ import { Filter, Obj, Query, Type } from '@dxos/echo';
12
10
  import {
13
- FormatEnum,
14
11
  EchoObject,
15
- GeneratorAnnotation,
16
12
  FormatAnnotation,
13
+ FormatEnum,
14
+ GeneratorAnnotation,
15
+ LabelAnnotation,
17
16
  PropertyMetaAnnotationId,
18
- } from '@dxos/echo-schema';
17
+ } from '@dxos/echo/internal';
18
+ import { live } from '@dxos/echo/internal';
19
19
  import { invariant } from '@dxos/invariant';
20
20
  import { faker } from '@dxos/random';
21
21
  import { PublicKey } from '@dxos/react-client';
22
- import { live, type Space } from '@dxos/react-client/echo';
22
+ import { type Space } from '@dxos/react-client/echo';
23
23
  import { withClientProvider } from '@dxos/react-client/testing';
24
+ import { withTheme } from '@dxos/react-ui/testing';
24
25
  import { ViewEditor } from '@dxos/react-ui-form';
25
26
  import { SyntaxHighlighter } from '@dxos/react-ui-syntax-highlighter';
26
- import { getSchemaFromPropertyDefinitions, DataType } from '@dxos/schema';
27
+ import { DataType, getSchemaFromPropertyDefinitions } from '@dxos/schema';
27
28
  import { Testing, createObjectFactory } from '@dxos/schema/testing';
28
- import { withLayout, withTheme } from '@dxos/storybook-utils';
29
29
 
30
- import { Table } from './Table';
31
30
  import { useTestTableModel } from '../../testing';
32
31
  import { translations } from '../../translations';
33
- import { TableView } from '../../types';
34
- import { createTable } from '../../util';
32
+ import { Table } from '../../types';
35
33
  import { TableToolbar } from '../TableToolbar';
36
34
 
35
+ import { Table as TableComponent } from './Table';
36
+
37
37
  const TestSchema = Schema.Struct({
38
38
  // TODO(wittjosiah): Should be title. Currently name to work with default label.
39
39
  name: Schema.optional(Schema.String).annotations({ title: 'Title' }),
@@ -58,7 +58,10 @@ const TestSchema = Schema.Struct({
58
58
  parent: Schema.optional(Schema.suspend((): Type.Ref<TestSchema> => Type.Ref(TestSchema))).annotations({
59
59
  title: 'Parent',
60
60
  }),
61
- }).pipe(Type.Obj({ typename: `example.com/type/${PublicKey.random().truncate()}`, version: '0.1.0' }));
61
+ }).pipe(
62
+ Type.Obj({ typename: `example.com/type/${PublicKey.random().truncate()}`, version: '0.1.0' }),
63
+ LabelAnnotation.set(['name']),
64
+ );
62
65
  interface TestSchema extends Schema.Schema.Type<typeof TestSchema> {}
63
66
 
64
67
  const StoryViewEditor = ({
@@ -72,13 +75,13 @@ const StoryViewEditor = ({
72
75
  space?: Space;
73
76
  handleDeleteColumn: (fieldId: string) => void;
74
77
  }) => {
75
- const handleTypenameChanged = useCallback(
78
+ const handleQueryChanged = useCallback(
76
79
  (typename: string) => {
77
80
  invariant(schema);
78
81
  invariant(Type.isMutable(schema));
79
82
  schema.updateTypename(typename);
80
83
  invariant(view);
81
- view.query.typename = typename;
84
+ view.query.ast = Query.select(Filter.typename(typename)).ast;
82
85
  },
83
86
  [schema, view],
84
87
  );
@@ -92,7 +95,7 @@ const StoryViewEditor = ({
92
95
  registry={space?.db.schemaRegistry}
93
96
  schema={schema}
94
97
  view={view}
95
- onTypenameChanged={handleTypenameChanged}
98
+ onQueryChanged={handleQueryChanged}
96
99
  onDelete={handleDeleteColumn}
97
100
  />
98
101
  );
@@ -124,8 +127,8 @@ const DefaultStory = () => {
124
127
  <div className='grow grid grid-cols-[1fr_350px]'>
125
128
  <div className='grid grid-rows-[min-content_1fr] min-bs-0 overflow-hidden'>
126
129
  <TableToolbar classNames='border-be border-subduedSeparator' onAdd={handleInsertRow} onSave={handleSaveView} />
127
- <Table.Root>
128
- <Table.Main
130
+ <TableComponent.Root>
131
+ <TableComponent.Main
129
132
  ref={tableRef}
130
133
  model={model}
131
134
  presentation={presentation}
@@ -133,11 +136,11 @@ const DefaultStory = () => {
133
136
  client={client}
134
137
  ignoreAttention
135
138
  />
136
- </Table.Root>
139
+ </TableComponent.Root>
137
140
  </div>
138
141
  <div className='flex flex-col h-full border-l border-separator overflow-y-auto'>
139
142
  <StoryViewEditor view={view} schema={schema} space={space} handleDeleteColumn={handleDeleteColumn} />
140
- <SyntaxHighlighter language='json' className='w-full text-xs'>
143
+ <SyntaxHighlighter language='json' className='text-xs'>
141
144
  {JSON.stringify({ view, schema }, null, 2)}
142
145
  </SyntaxHighlighter>
143
146
  </div>
@@ -153,26 +156,23 @@ type StoryProps = { rows?: number };
153
156
 
154
157
  // TODO(burdon): Need super simple story.
155
158
 
156
- const meta: Meta<StoryProps> = {
159
+ const meta = {
157
160
  title: 'ui/react-ui-table/Table',
158
161
  render: DefaultStory,
159
- parameters: {
160
- translations,
161
- layout: 'fullscreen',
162
- controls: {
163
- disable: true,
164
- },
165
- },
166
162
  decorators: [
167
163
  withTheme,
168
- withLayout({ fullscreen: true }),
169
164
  withClientProvider({
170
- types: [DataType.View, TableView],
165
+ types: [DataType.View, Table.Table],
171
166
  createIdentity: true,
172
167
  createSpace: true,
173
- onSpaceCreated: async ({ client, space }) => {
168
+ onCreateSpace: async ({ client, space }) => {
174
169
  const [schema] = await space.db.schemaRegistry.register([TestSchema]);
175
- const { view } = await createTable({ client, space, typename: schema.typename });
170
+ const { view } = await Table.makeView({ client, space, typename: schema.typename });
171
+ view.projection.fields = [
172
+ view.projection.fields.find((field) => field.path === 'name')!,
173
+ ...view.projection.fields.filter((field) => field.path !== 'name'),
174
+ ];
175
+
176
176
  space.db.add(view);
177
177
 
178
178
  Array.from({ length: 10 }).map(() => {
@@ -187,22 +187,30 @@ const meta: Meta<StoryProps> = {
187
187
  },
188
188
  }),
189
189
  ],
190
- };
190
+ parameters: {
191
+ layout: 'fullscreen',
192
+ controls: {
193
+ disable: true,
194
+ },
195
+ translations,
196
+ },
197
+ } satisfies Meta<typeof TableComponent>;
191
198
 
192
199
  export default meta;
193
200
 
194
- export const Default = {};
201
+ type Story = StoryObj<typeof meta>;
202
+
203
+ export const Default: Story = {};
195
204
 
196
205
  export const StaticSchema: StoryObj = {
197
206
  render: DefaultStory,
198
- parameters: { translations },
199
207
  decorators: [
200
208
  withClientProvider({
201
- types: [DataType.View, TableView, Testing.Contact, Testing.Organization],
209
+ types: [DataType.View, Table.Table, Testing.Contact, Testing.Organization],
202
210
  createIdentity: true,
203
211
  createSpace: true,
204
- onSpaceCreated: async ({ client, space }) => {
205
- const { view } = await createTable({ client, space, typename: Testing.Contact.typename });
212
+ onCreateSpace: async ({ client, space }) => {
213
+ const { view } = await Table.makeView({ client, space, typename: Testing.Contact.typename });
206
214
  space.db.add(view);
207
215
 
208
216
  const factory = createObjectFactory(space.db, faker as any);
@@ -212,9 +220,11 @@ export const StaticSchema: StoryObj = {
212
220
  ]);
213
221
  },
214
222
  }),
215
- withLayout({ fullscreen: true }),
216
- withTheme,
217
223
  ],
224
+ parameters: {
225
+ layout: 'fullscreen',
226
+ translations,
227
+ },
218
228
  };
219
229
 
220
230
  const ContactWithArrayOfEmails = Schema.Struct({
@@ -236,14 +246,13 @@ const ContactWithArrayOfEmails = Schema.Struct({
236
246
 
237
247
  export const ArrayOfObjects: StoryObj = {
238
248
  render: DefaultStory,
239
- parameters: { translations },
240
249
  decorators: [
241
250
  withClientProvider({
242
- types: [DataType.View, TableView, Testing.Contact, Testing.Organization, ContactWithArrayOfEmails],
251
+ types: [DataType.View, Table.Table, Testing.Contact, Testing.Organization, ContactWithArrayOfEmails],
243
252
  createIdentity: true,
244
253
  createSpace: true,
245
- onSpaceCreated: async ({ client, space }) => {
246
- const { view } = await createTable({ client, space, typename: ContactWithArrayOfEmails.typename });
254
+ onCreateSpace: async ({ client, space }) => {
255
+ const { view } = await Table.makeView({ client, space, typename: ContactWithArrayOfEmails.typename });
247
256
  space.db.add(view);
248
257
 
249
258
  const factory = createObjectFactory(space.db, faker as any);
@@ -254,21 +263,22 @@ export const ArrayOfObjects: StoryObj = {
254
263
  ]);
255
264
  },
256
265
  }),
257
- withLayout({ fullscreen: true }),
258
- withTheme,
259
266
  ],
267
+ parameters: {
268
+ layout: 'fullscreen',
269
+ translations,
270
+ },
260
271
  };
261
272
 
262
273
  export const Tags: Meta<StoryProps> = {
263
274
  title: 'ui/react-ui-table/Table',
264
275
  render: DefaultStory,
265
- parameters: { translations },
266
276
  decorators: [
267
277
  withClientProvider({
268
- types: [DataType.View, TableView],
278
+ types: [DataType.View, Table.Table],
269
279
  createIdentity: true,
270
280
  createSpace: true,
271
- onSpaceCreated: async ({ client, space }) => {
281
+ onCreateSpace: async ({ client, space }) => {
272
282
  // Configure schema.
273
283
  const typename = 'example.com/SingleSelect';
274
284
  const selectOptions = [
@@ -296,7 +306,7 @@ export const Tags: Meta<StoryProps> = {
296
306
  const [storedSchema] = await space.db.schemaRegistry.register([schema]);
297
307
 
298
308
  // Initialize table.
299
- const { view } = await createTable({ client, space, typename });
309
+ const { view } = await Table.makeView({ client, space, typename });
300
310
  space.db.add(view);
301
311
 
302
312
  // Populate.
@@ -310,7 +320,9 @@ export const Tags: Meta<StoryProps> = {
310
320
  });
311
321
  },
312
322
  }),
313
- withLayout({ fullscreen: true }),
314
- withTheme,
315
323
  ],
324
+ parameters: {
325
+ layout: 'fullscreen',
326
+ translations,
327
+ },
316
328
  };
@@ -2,49 +2,47 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type Schema } from 'effect/Schema';
5
+ import type * as Schema from 'effect/Schema';
6
+ import * as String from 'effect/String';
6
7
  import React, {
7
8
  type MouseEvent,
8
9
  type PropsWithChildren,
9
10
  type WheelEvent,
10
11
  forwardRef,
11
12
  useCallback,
13
+ useEffect,
12
14
  useImperativeHandle,
13
- useState,
14
15
  useMemo,
15
- useEffect,
16
+ useState,
16
17
  } from 'react';
17
18
 
18
- import { type Client } from '@dxos/client';
19
- import { Filter } from '@dxos/echo';
20
- import { getValue } from '@dxos/echo-schema';
21
- import { invariant } from '@dxos/invariant';
22
19
  // TODO(wittjosiah): Remove dependency on react-client.
23
- import { getSpace } from '@dxos/react-client/echo';
24
- import { useTranslation } from '@dxos/react-ui';
20
+ import { type Client } from '@dxos/react-client';
25
21
  import { useAttention } from '@dxos/react-ui-attention';
26
22
  import {
27
- closestCell,
28
23
  type DxGridElement,
29
- type DxGridPosition,
30
- type GridContentProps,
31
- Grid,
32
24
  type DxGridPlane,
33
25
  type DxGridPlaneRange,
34
- gridSeparatorInlineEnd,
26
+ type DxGridPosition,
27
+ Grid,
28
+ type GridContentProps,
29
+ closestCell,
35
30
  gridSeparatorBlockEnd,
31
+ gridSeparatorInlineEnd,
36
32
  } from '@dxos/react-ui-grid';
33
+ import { DxEditRequest } from '@dxos/react-ui-grid';
37
34
  import { mx } from '@dxos/react-ui-theme';
38
- import { isNotFalsy, safeParseInt } from '@dxos/util';
35
+
36
+ import { type InsertRowResult, ModalController, type TableModel, type TablePresentation } from '../../model';
37
+ import { tableButtons, tableControls } from '../../util';
38
+ import { type OnCreateHandler, type TableCellEditorProps, TableValueEditor } from '../TableCellEditor';
39
39
 
40
40
  import { ColumnActionsMenu } from './ColumnActionsMenu';
41
41
  import { ColumnSettings } from './ColumnSettings';
42
- import { CreateRefPanel } from './CreateRefPanel';
43
42
  import { RowActionsMenu } from './RowActionsMenu';
44
- import { ModalController, type TableModel, type TablePresentation } from '../../model';
45
- import { translationKey } from '../../translations';
46
- import { tableButtons, tableControls } from '../../util';
47
- import { createOption, TableValueEditor, type TableCellEditorProps } from '../TableCellEditor';
43
+
44
+ const columnDefault = { grid: { minSize: 80, maxSize: 640 } };
45
+ const rowDefault = { frozenRowsStart: { readonly: true, focusUnfurl: false } };
48
46
 
49
47
  //
50
48
  // Table.Root
@@ -76,27 +74,34 @@ const TableRoot = ({ children, role = 'article' }: TableRootProps) => {
76
74
 
77
75
  export type TableController = {
78
76
  update?: (cell?: DxGridPosition) => void;
77
+ handleInsertRowResult?: (insertRowResult?: InsertRowResult) => void;
79
78
  };
80
79
 
81
80
  export type TableMainProps = {
82
81
  model?: TableModel;
83
82
  presentation?: TablePresentation;
84
- schema?: Schema.AnyNoContext;
83
+ schema?: Schema.Schema.AnyNoContext;
85
84
  client?: Client;
86
85
  // TODO(burdon): Rename since attention isn't a useful concept here? Standardize across other components. Pass property into useAttention.
87
86
  ignoreAttention?: boolean;
87
+ onCreate?: OnCreateHandler;
88
88
  onRowClick?: (row: any) => void;
89
+ testId?: string;
89
90
  };
90
91
 
91
92
  const TableMain = forwardRef<TableController, TableMainProps>(
92
- ({ model, presentation, ignoreAttention, schema, client, onRowClick }, forwardedRef) => {
93
+ ({ model, presentation, ignoreAttention, schema, client, onCreate, onRowClick, testId }, forwardedRef) => {
93
94
  const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
94
95
  const { hasAttention } = useAttention(model?.id ?? 'table');
95
- const { t } = useTranslation(translationKey);
96
96
  const modals = useMemo(() => new ModalController(), []);
97
97
 
98
98
  const draftRowCount = model?.getDraftRowCount() ?? 0;
99
99
 
100
+ const handleSave = useCallback(() => {
101
+ dxGrid?.updateCells(true);
102
+ dxGrid?.requestUpdate();
103
+ }, [dxGrid]);
104
+
100
105
  const frozen = useMemo(() => {
101
106
  const noActionColumn =
102
107
  model?.features.dataEditable === false &&
@@ -105,7 +110,7 @@ const TableMain = forwardRef<TableController, TableMainProps>(
105
110
 
106
111
  return {
107
112
  frozenRowsStart: 1,
108
- frozenRowsEnd: draftRowCount,
113
+ frozenRowsEnd: Math.max(1, draftRowCount),
109
114
  frozenColsStart: model?.features.selection.enabled ? 1 : 0,
110
115
  frozenColsEnd: noActionColumn ? 0 : 1,
111
116
  };
@@ -123,6 +128,23 @@ const TableMain = forwardRef<TableController, TableMainProps>(
123
128
  dxGrid.getCells = getCells;
124
129
  }, [presentation, dxGrid, getCells]);
125
130
 
131
+ const handleInsertRowResult = useCallback(
132
+ (insertResult?: InsertRowResult) => {
133
+ if (insertResult === 'draft') {
134
+ requestAnimationFrame(() => {
135
+ dxGrid?.setFocus({ plane: 'frozenRowsEnd', col: 0, row: 0 });
136
+ dxGrid?.refocus();
137
+ });
138
+ } else {
139
+ requestAnimationFrame(() => {
140
+ dxGrid?.setFocus({ plane: 'grid', col: 0, row: model ? model.getRowCount() - 1 : 0 });
141
+ dxGrid?.refocus();
142
+ });
143
+ }
144
+ },
145
+ [model, dxGrid],
146
+ );
147
+
126
148
  /**
127
149
  * Provides an external controller that can be called to repaint the table.
128
150
  */
@@ -140,16 +162,36 @@ const TableMain = forwardRef<TableController, TableMainProps>(
140
162
  dxGrid.requestUpdate();
141
163
  }
142
164
  },
165
+ handleInsertRowResult,
143
166
  };
144
- }, [presentation, dxGrid]);
167
+ }, [presentation, dxGrid, model]);
168
+
169
+ const handleSaveDraftRow = useCallback(
170
+ (rowIndex = 0) => {
171
+ if (model && dxGrid) {
172
+ const didCommitSuccessfully = model.commitDraftRow(rowIndex);
173
+ if (didCommitSuccessfully) {
174
+ requestAnimationFrame(() => {
175
+ dxGrid.scrollToEndRow();
176
+ });
177
+ }
178
+ }
179
+ },
180
+ [model, dxGrid],
181
+ );
145
182
 
146
183
  const handleGridClick = useCallback(
147
184
  (event: MouseEvent) => {
148
- const rowIndex = safeParseInt((event.target as HTMLElement).closest('[aria-rowindex]')?.ariaRowIndex ?? '');
149
- if (rowIndex != null) {
185
+ const cell = closestCell(event.target as HTMLElement);
186
+ if (cell) {
187
+ const { row: rowIndex, plane } = cell;
150
188
  if (onRowClick) {
151
- const row = model?.getRowAt(rowIndex);
152
- row && onRowClick(row);
189
+ if (plane === 'grid') {
190
+ const row = model?.getRowAt(rowIndex);
191
+ row && onRowClick(row);
192
+ } else {
193
+ onRowClick(cell);
194
+ }
153
195
  }
154
196
 
155
197
  if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
@@ -192,14 +234,7 @@ const TableMain = forwardRef<TableController, TableMainProps>(
192
234
  break;
193
235
  }
194
236
  case 'saveDraftRow': {
195
- if (model) {
196
- const didCommitSuccessfully = model.commitDraftRow(data.rowIndex);
197
- if (dxGrid && didCommitSuccessfully) {
198
- requestAnimationFrame(() => {
199
- dxGrid.scrollToEndRow();
200
- });
201
- }
202
- }
237
+ handleSaveDraftRow();
203
238
  break;
204
239
  }
205
240
  }
@@ -240,32 +275,19 @@ const TableMain = forwardRef<TableController, TableMainProps>(
240
275
  );
241
276
 
242
277
  const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
243
- (increment, delta) => {
244
- if (dxGrid) {
245
- dxGrid.refocus(increment, delta);
246
- }
247
- },
248
- [dxGrid],
249
- );
250
-
251
- const handleEnter = useCallback<NonNullable<TableCellEditorProps['onEnter']>>(
252
- (cell) => {
253
- if (!model?.features.dataEditable) {
254
- return;
255
- }
256
-
257
- // TODO(burdon): Insert row only if bottom row isn't completely blank already.
258
- if (model && cell.row === model.getRowCount() - 1) {
259
- model.insertRow();
260
- if (dxGrid) {
261
- requestAnimationFrame(() => {
262
- dxGrid?.scrollToRow(cell.row + 1);
263
- dxGrid?.refocus('row', 1);
264
- });
278
+ (increment, delta, cell) => {
279
+ if (dxGrid && model) {
280
+ if (cell?.plane === 'grid' && cell?.row >= model.getRowCount() - 1 && increment !== 'col') {
281
+ handleInsertRowResult(draftRowCount < 1 ? model.insertRow() : 'final');
282
+ } else if (cell?.plane === 'frozenRowsEnd' && increment === 'row') {
283
+ handleSaveDraftRow(cell.row);
284
+ handleInsertRowResult(model.insertRow());
285
+ } else {
286
+ dxGrid.refocus(increment, delta);
265
287
  }
266
288
  }
267
289
  },
268
- [model, dxGrid],
290
+ [dxGrid, model],
269
291
  );
270
292
 
271
293
  const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
@@ -279,15 +301,72 @@ const TableMain = forwardRef<TableController, TableMainProps>(
279
301
  return;
280
302
  }
281
303
 
304
+ // Handle Meta+C (Copy) and Meta+V (Paste) commands
305
+ if (event.metaKey || event.ctrlKey) {
306
+ switch (event.key) {
307
+ case 'c': {
308
+ // Copy focused cell's text content to clipboard
309
+ try {
310
+ const cellData = model.getCellData(cell);
311
+ const textContent = cellData?.toString() ?? '';
312
+ void navigator.clipboard.writeText(textContent);
313
+ event.preventDefault();
314
+ } catch (error) {
315
+ console.warn('Failed to copy cell content:', error);
316
+ }
317
+ break;
318
+ }
319
+ case 'v': {
320
+ // Paste clipboard content to focused cell
321
+ event.preventDefault();
322
+ void navigator.clipboard.readText().then((clipboardText) => {
323
+ try {
324
+ // Attempt to set the cell's content to clipboard content
325
+ model.setCellData(cell, String.trim(clipboardText).replace(/[\n\r]+/, ' '));
326
+ handleSave();
327
+ } catch {
328
+ // If validation fails, emit a DxEditRequest event with initialContent from clipboard
329
+ // TODO(thure): Should `dx-grid` expose a method like this?
330
+ const cellElement = (event.target as HTMLElement).closest(
331
+ '[data-dx-grid-action="cell"]',
332
+ ) as HTMLElement;
333
+ if (cellElement) {
334
+ const rect = cellElement.getBoundingClientRect();
335
+ const editRequest = new DxEditRequest({
336
+ cellIndex: `${cell.plane},${cell.col},${cell.row}`,
337
+ cellBox: {
338
+ insetInlineStart: rect.left,
339
+ insetBlockStart: rect.top,
340
+ inlineSize: rect.width,
341
+ blockSize: rect.height,
342
+ },
343
+ cellElement,
344
+ initialContent: clipboardText,
345
+ });
346
+ cellElement.dispatchEvent(editRequest);
347
+ }
348
+ }
349
+ });
350
+ break;
351
+ }
352
+ }
353
+ }
354
+
282
355
  switch (event.key) {
283
356
  case 'Backspace':
284
357
  case 'Delete': {
285
- model.setCellData(cell, undefined);
358
+ try {
359
+ model.setCellData(cell, undefined);
360
+ event.preventDefault();
361
+ handleSave();
362
+ } catch {
363
+ // Delete results in a validation error; don’t prevent default so dx-grid can emit an edit request.
364
+ }
286
365
  break;
287
366
  }
288
367
  }
289
368
  },
290
- [model],
369
+ [model, dxGrid, handleSave],
291
370
  );
292
371
 
293
372
  const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
@@ -316,57 +395,6 @@ const TableMain = forwardRef<TableController, TableMainProps>(
316
395
  }
317
396
  }, [model, dxGrid]);
318
397
 
319
- // TODO(burdon): Factor out?
320
- // TODO(burdon): Generalize to handle other value types (e.g., enums).
321
- const handleQuery = useCallback<NonNullable<TableCellEditorProps['onQuery']>>(
322
- async ({ field, props }, text) => {
323
- if (model && props.referenceSchema && field.referencePath) {
324
- const space = getSpace(model.view);
325
- invariant(space);
326
-
327
- let schema;
328
- if (client) {
329
- schema = client.graph.schemaRegistry.getSchema(props.referenceSchema);
330
- }
331
- if (!schema) {
332
- schema = space.db.schemaRegistry.getSchema(props.referenceSchema);
333
- }
334
-
335
- if (schema) {
336
- const { objects } = await space.db.query(Filter.type(schema)).run();
337
- const options = objects
338
- .map((obj) => {
339
- const value = getValue(obj, field.referencePath!);
340
- if (!value || typeof value !== 'string') {
341
- return undefined;
342
- }
343
-
344
- return {
345
- label: value,
346
- data: obj,
347
- };
348
- })
349
- .filter(isNotFalsy);
350
-
351
- return [
352
- ...options,
353
- {
354
- label: t('create new object label', { text }),
355
- data: createOption(text),
356
- },
357
- ];
358
- }
359
- }
360
-
361
- return [];
362
- },
363
- [model, client, t],
364
- );
365
-
366
- const handleSave = useCallback(() => {
367
- dxGrid?.updateCells(true);
368
- }, [dxGrid]);
369
-
370
398
  if (!model || !modals) {
371
399
  return <span role='none' className='attention-surface' />;
372
400
  }
@@ -377,28 +405,30 @@ const TableMain = forwardRef<TableController, TableMainProps>(
377
405
  model={model}
378
406
  modals={modals}
379
407
  schema={schema}
380
- onEnter={handleEnter}
381
408
  onFocus={handleFocus}
382
- onQuery={handleQuery}
383
409
  onSave={handleSave}
410
+ onCreate={onCreate}
411
+ client={client}
384
412
  />
385
413
  <Grid.Content
386
414
  className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
387
415
  frozen={frozen}
388
416
  columns={model.columnMeta.value}
417
+ columnDefault={columnDefault}
418
+ rowDefault={rowDefault}
389
419
  limitRows={model.getRowCount() ?? 0}
390
420
  limitColumns={model.projection.fields.length}
391
421
  overscroll='trap'
392
422
  onAxisResize={handleAxisResize}
393
423
  onClick={handleGridClick}
394
- onKeyDown={handleKeyDown}
424
+ onKeyDownCapture={handleKeyDown}
395
425
  onWheelCapture={handleWheel}
426
+ {...(testId && { 'data-testid': testId })}
396
427
  ref={setDxGrid}
397
428
  />
398
429
  <RowActionsMenu model={model} modals={modals} />
399
430
  <ColumnActionsMenu model={model} modals={modals} />
400
431
  <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
401
- <CreateRefPanel model={model} modals={modals} />
402
432
  </Grid.Root>
403
433
  );
404
434
  },
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { useGridContext, type GridScopedProps } from '@dxos/react-ui-grid';
7
+ import { type GridScopedProps, useGridContext } from '@dxos/react-ui-grid';
8
8
 
9
9
  export type CellValidationMessageProps = {
10
10
  validationError: string | null;