@dxos/react-ui-table 0.8.4-main.67995b8 → 0.8.4-main.69d29f4

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 (155) hide show
  1. package/dist/lib/browser/chunk-I5IFW7UF.mjs +1746 -0
  2. package/dist/lib/browser/chunk-I5IFW7UF.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-OZBLY7XM.mjs +76 -0
  4. package/dist/lib/browser/chunk-OZBLY7XM.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +1163 -1120
  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 +30 -37
  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-DDJKYJFY.mjs +1748 -0
  12. package/dist/lib/node-esm/chunk-DDJKYJFY.mjs.map +7 -0
  13. package/dist/lib/node-esm/chunk-WNZSKIRF.mjs +77 -0
  14. package/dist/lib/node-esm/chunk-WNZSKIRF.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +1163 -1120
  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 +30 -37
  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 +2 -3
  22. package/dist/types/src/components/Table/ColumnActionsMenu.d.ts.map +1 -1
  23. package/dist/types/src/components/Table/ColumnSettings.d.ts +3 -3
  24. package/dist/types/src/components/Table/ColumnSettings.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/DynamicTable.d.ts +5 -6
  26. package/dist/types/src/components/Table/DynamicTable.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +37 -3
  28. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Table/Relations.stories.d.ts +111 -0
  30. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Table/RowActionsMenu.d.ts +1 -1
  32. package/dist/types/src/components/Table/RowActionsMenu.d.ts.map +1 -1
  33. package/dist/types/src/components/Table/Table.d.ts +13 -9
  34. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  35. package/dist/types/src/components/Table/Table.stories.d.ts +108 -4
  36. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  37. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts +1 -1
  38. package/dist/types/src/components/TableCellEditor/CellValidationMessage.d.ts.map +1 -1
  39. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts +13 -9
  40. package/dist/types/src/components/TableCellEditor/FormCellEditor.d.ts.map +1 -1
  41. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts +11 -18
  42. package/dist/types/src/components/TableCellEditor/TableCellEditor.d.ts.map +1 -1
  43. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +39 -4
  44. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/TableCellEditor/index.d.ts +1 -0
  46. package/dist/types/src/components/TableCellEditor/index.d.ts.map +1 -1
  47. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts +4 -4
  48. package/dist/types/src/components/TableToolbar/TableToolbar.d.ts.map +1 -1
  49. package/dist/types/src/components/testing.d.ts +23 -16
  50. package/dist/types/src/components/testing.d.ts.map +1 -1
  51. package/dist/types/src/hooks/index.d.ts +2 -1
  52. package/dist/types/src/hooks/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/useAddRow.d.ts +6 -6
  54. package/dist/types/src/hooks/useAddRow.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useProjectionModel.d.ts +6 -0
  56. package/dist/types/src/hooks/useProjectionModel.d.ts.map +1 -0
  57. package/dist/types/src/hooks/useTableModel.d.ts +9 -9
  58. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  59. package/dist/types/src/model/modal-controller.d.ts +5 -8
  60. package/dist/types/src/model/modal-controller.d.ts.map +1 -1
  61. package/dist/types/src/model/selection-model.d.ts +17 -8
  62. package/dist/types/src/model/selection-model.d.ts.map +1 -1
  63. package/dist/types/src/model/table-model.d.ts +109 -28
  64. package/dist/types/src/model/table-model.d.ts.map +1 -1
  65. package/dist/types/src/model/table-presentation.d.ts +5 -2
  66. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  67. package/dist/types/src/playwright/TableManager.d.ts +1 -1
  68. package/dist/types/src/playwright/TableManager.d.ts.map +1 -1
  69. package/dist/types/src/testing/useTestTableModel.d.ts +18 -34
  70. package/dist/types/src/testing/useTestTableModel.d.ts.map +1 -1
  71. package/dist/types/src/translations.d.ts +4 -4
  72. package/dist/types/src/translations.d.ts.map +1 -1
  73. package/dist/types/src/types/Table.d.ts +31 -0
  74. package/dist/types/src/types/Table.d.ts.map +1 -0
  75. package/dist/types/src/types/index.d.ts +1 -1
  76. package/dist/types/src/types/index.d.ts.map +1 -1
  77. package/dist/types/src/util/dynamic-table.d.ts +13 -12
  78. package/dist/types/src/util/dynamic-table.d.ts.map +1 -1
  79. package/dist/types/src/util/index.d.ts +2 -1
  80. package/dist/types/src/util/index.d.ts.map +1 -1
  81. package/dist/types/src/util/query-ast.d.ts +7 -0
  82. package/dist/types/src/util/query-ast.d.ts.map +1 -0
  83. package/dist/types/src/util/schema.d.ts +1 -1
  84. package/dist/types/src/util/schema.d.ts.map +1 -1
  85. package/dist/types/src/util/sort.d.ts +12 -0
  86. package/dist/types/src/util/sort.d.ts.map +1 -0
  87. package/dist/types/src/util/table-buttons.d.ts.map +1 -1
  88. package/dist/types/src/util/table-controls.d.ts.map +1 -1
  89. package/dist/types/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +65 -64
  91. package/src/components/Table/ColumnActionsMenu.tsx +11 -7
  92. package/src/components/Table/ColumnSettings.tsx +7 -7
  93. package/src/components/Table/DynamicTable.stories.tsx +25 -22
  94. package/src/components/Table/DynamicTable.tsx +26 -21
  95. package/src/components/Table/Relations.stories.tsx +237 -0
  96. package/src/components/Table/RowActionsMenu.tsx +26 -17
  97. package/src/components/Table/Table.stories.tsx +110 -102
  98. package/src/components/Table/Table.tsx +353 -298
  99. package/src/components/TableCellEditor/CellValidationMessage.tsx +2 -2
  100. package/src/components/TableCellEditor/FormCellEditor.tsx +128 -40
  101. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +30 -66
  102. package/src/components/TableCellEditor/TableCellEditor.tsx +88 -238
  103. package/src/components/TableCellEditor/index.ts +1 -0
  104. package/src/components/TableToolbar/TableToolbar.tsx +49 -33
  105. package/src/components/testing.ts +39 -13
  106. package/src/hooks/index.ts +2 -1
  107. package/src/hooks/useAddRow.ts +14 -14
  108. package/src/hooks/useProjectionModel.ts +39 -0
  109. package/src/hooks/useTableModel.ts +35 -33
  110. package/src/model/modal-controller.ts +24 -41
  111. package/src/model/selection-model.ts +75 -43
  112. package/src/model/table-model.test.ts +92 -55
  113. package/src/model/table-model.ts +445 -168
  114. package/src/model/table-presentation.test.ts +70 -58
  115. package/src/model/table-presentation.ts +103 -57
  116. package/src/playwright/TableManager.ts +5 -5
  117. package/src/playwright/playwright.config.ts +1 -1
  118. package/src/playwright/smoke.spec.ts +135 -40
  119. package/src/testing/useTestTableModel.ts +45 -34
  120. package/src/translations.ts +3 -3
  121. package/src/types/Table.ts +90 -0
  122. package/src/types/index.ts +1 -1
  123. package/src/util/dynamic-table.ts +44 -32
  124. package/src/util/index.ts +2 -1
  125. package/src/util/query-ast.ts +19 -0
  126. package/src/util/schema.ts +4 -4
  127. package/src/util/sort.ts +90 -0
  128. package/src/util/table-buttons.ts +4 -3
  129. package/src/util/table-controls.ts +4 -1
  130. package/dist/lib/browser/chunk-5XLSHLOG.mjs +0 -1751
  131. package/dist/lib/browser/chunk-5XLSHLOG.mjs.map +0 -7
  132. package/dist/lib/browser/chunk-L2LSB6KV.mjs +0 -18
  133. package/dist/lib/browser/chunk-L2LSB6KV.mjs.map +0 -7
  134. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs +0 -20
  135. package/dist/lib/node-esm/chunk-NXZ4CJYX.mjs.map +0 -7
  136. package/dist/lib/node-esm/chunk-Y66YLSTC.mjs +0 -1752
  137. package/dist/lib/node-esm/chunk-Y66YLSTC.mjs.map +0 -7
  138. package/dist/types/src/components/Table/CreateRefPanel.d.ts +0 -9
  139. package/dist/types/src/components/Table/CreateRefPanel.d.ts.map +0 -1
  140. package/dist/types/src/components/Table/relations.stories.d.ts +0 -9
  141. package/dist/types/src/components/Table/relations.stories.d.ts.map +0 -1
  142. package/dist/types/src/components/TableCellEditor/extension.d.ts +0 -13
  143. package/dist/types/src/components/TableCellEditor/extension.d.ts.map +0 -1
  144. package/dist/types/src/model/table-sorting.d.ts +0 -53
  145. package/dist/types/src/model/table-sorting.d.ts.map +0 -1
  146. package/dist/types/src/types/schema.d.ts +0 -8
  147. package/dist/types/src/types/schema.d.ts.map +0 -1
  148. package/dist/types/src/util/table.d.ts +0 -11
  149. package/dist/types/src/util/table.d.ts.map +0 -1
  150. package/src/components/Table/CreateRefPanel.tsx +0 -86
  151. package/src/components/Table/relations.stories.tsx +0 -116
  152. package/src/components/TableCellEditor/extension.ts +0 -61
  153. package/src/model/table-sorting.ts +0 -216
  154. package/src/types/schema.ts +0 -16
  155. package/src/util/table.ts +0 -49
@@ -2,49 +2,52 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type Schema } from 'effect/Schema';
5
+ import { Atom, RegistryContext, useAtomValue } from '@effect-atom/atom-react';
6
+ import * as String from 'effect/String';
6
7
  import React, {
8
+ type JSX,
7
9
  type MouseEvent,
8
10
  type PropsWithChildren,
11
+ type Ref,
9
12
  type WheelEvent,
10
13
  forwardRef,
11
14
  useCallback,
15
+ useContext,
16
+ useEffect,
12
17
  useImperativeHandle,
13
- useState,
14
18
  useMemo,
15
- useEffect,
19
+ useState,
16
20
  } from 'react';
17
21
 
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
- // TODO(wittjosiah): Remove dependency on react-client.
23
- import { getSpace } from '@dxos/react-client/echo';
24
- import { useTranslation } from '@dxos/react-ui';
22
+ import { type Type } from '@dxos/echo';
23
+ import { log } from '@dxos/log';
25
24
  import { useAttention } from '@dxos/react-ui-attention';
26
25
  import {
27
- closestCell,
26
+ type DxGridAxisMeta,
28
27
  type DxGridElement,
29
- type DxGridPosition,
30
- type GridContentProps,
31
- Grid,
32
28
  type DxGridPlane,
33
29
  type DxGridPlaneRange,
34
- gridSeparatorInlineEnd,
30
+ type DxGridPosition,
31
+ Grid,
32
+ type GridContentProps,
33
+ closestCell,
35
34
  gridSeparatorBlockEnd,
35
+ gridSeparatorInlineEnd,
36
36
  } from '@dxos/react-ui-grid';
37
- import { mx } from '@dxos/react-ui-theme';
38
- import { isNotFalsy, safeParseInt } from '@dxos/util';
37
+ import { DxEditRequest } from '@dxos/react-ui-grid';
38
+ import { mx } from '@dxos/ui-theme';
39
+
40
+ import { type InsertRowResult, ModalController, type TableModel, type TablePresentation } from '../../model';
41
+ import { tableButtons, tableControls } from '../../util';
42
+ import { type OnCreateHandler, type TableCellEditorProps, TableValueEditor } from '../TableCellEditor';
39
43
 
40
44
  import { ColumnActionsMenu } from './ColumnActionsMenu';
41
45
  import { ColumnSettings } from './ColumnSettings';
42
- import { CreateRefPanel } from './CreateRefPanel';
43
46
  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';
47
+
48
+ const columnDefault = { grid: { minSize: 80, maxSize: 640 } };
49
+ const rowDefault = { frozenRowsStart: { readonly: true, focusUnfurl: false } };
50
+ const emptyColumnMeta = Atom.make<DxGridAxisMeta>({ grid: {} });
48
51
 
49
52
  //
50
53
  // Table.Root
@@ -76,338 +79,390 @@ const TableRoot = ({ children, role = 'article' }: TableRootProps) => {
76
79
 
77
80
  export type TableController = {
78
81
  update?: (cell?: DxGridPosition) => void;
82
+ handleInsertRowResult?: (insertRowResult?: InsertRowResult) => void;
79
83
  };
80
84
 
81
- export type TableMainProps = {
85
+ export type TableMainProps<T extends Type.Entity.Any = Type.Entity.Any> = {
86
+ schema?: T;
82
87
  model?: TableModel;
83
88
  presentation?: TablePresentation;
84
- schema?: Schema.AnyNoContext;
85
- client?: Client;
86
89
  // TODO(burdon): Rename since attention isn't a useful concept here? Standardize across other components. Pass property into useAttention.
87
90
  ignoreAttention?: boolean;
91
+ onCreate?: OnCreateHandler;
88
92
  onRowClick?: (row: any) => void;
93
+ testId?: string;
89
94
  };
90
95
 
91
- const TableMain = forwardRef<TableController, TableMainProps>(
92
- ({ model, presentation, ignoreAttention, schema, client, onRowClick }, forwardedRef) => {
93
- const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
94
- const { hasAttention } = useAttention(model?.id ?? 'table');
95
- const { t } = useTranslation(translationKey);
96
- const modals = useMemo(() => new ModalController(), []);
97
-
98
- const draftRowCount = model?.getDraftRowCount() ?? 0;
99
-
100
- const frozen = useMemo(() => {
101
- const noActionColumn =
102
- model?.features.dataEditable === false &&
103
- model?.features.schemaEditable === false &&
104
- model.rowActions.length === 0;
105
-
106
- return {
107
- frozenRowsStart: 1,
108
- frozenRowsEnd: draftRowCount,
109
- frozenColsStart: model?.features.selection.enabled ? 1 : 0,
110
- frozenColsEnd: noActionColumn ? 0 : 1,
111
- };
112
- }, [model, draftRowCount]);
113
-
114
- const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
115
- (range: DxGridPlaneRange, plane: DxGridPlane) => presentation?.getCells(range, plane) ?? {},
116
- [presentation],
117
- );
118
-
119
- useEffect(() => {
120
- if (!presentation || !dxGrid) {
121
- return;
96
+ const emptyRowsAtom = Atom.make<unknown[]>([]);
97
+ const emptyCellUpdateAtom = Atom.make<number>(0);
98
+
99
+ const TableMainInner = <T extends Type.Entity.Any = Type.Entity.Any>(
100
+ { schema, model, presentation, ignoreAttention, onCreate, onRowClick, testId }: TableMainProps<T>,
101
+ forwardedRef: Ref<TableController>,
102
+ ) => {
103
+ const registry = useContext(RegistryContext);
104
+ const [dxGrid, setDxGrid] = useState<DxGridElement | null>(null);
105
+ const { hasAttention } = useAttention(model?.id ?? 'table');
106
+ const modals = useMemo(() => new ModalController(registry), [registry]);
107
+ const columnMeta = useAtomValue(model?.columnMeta ?? emptyColumnMeta);
108
+ // Subscribe to rows atom to trigger re-render when rows change.
109
+ const rows = useAtomValue(model?.rows ?? emptyRowsAtom);
110
+ // Subscribe to cell update counter to trigger re-render when individual cells change.
111
+ const cellUpdateCounter = useAtomValue(model?.cellUpdate ?? emptyCellUpdateAtom);
112
+ // Derive column count from columnMeta (reactive) with fallback.
113
+ const columnCount = Object.keys(columnMeta.grid).length || model?.projection.getFields().length || 0;
114
+
115
+ const draftRowCount = model?.getDraftRowCount() ?? 0;
116
+
117
+ const frozen = useMemo(() => {
118
+ const noActionColumn =
119
+ model?.features.dataEditable === false &&
120
+ model?.features.schemaEditable === false &&
121
+ model.rowActions.length === 0;
122
+
123
+ return {
124
+ frozenRowsStart: 1,
125
+ frozenRowsEnd: Math.max(1, draftRowCount),
126
+ frozenColsStart: model?.features.selection.enabled ? 1 : 0,
127
+ frozenColsEnd: noActionColumn ? 0 : 1,
128
+ };
129
+ }, [model, draftRowCount]);
130
+
131
+ const getCells = useCallback<NonNullable<GridContentProps['getCells']>>(
132
+ (range: DxGridPlaneRange, plane: DxGridPlane) => presentation?.getCells(range, plane) ?? {},
133
+ [presentation],
134
+ );
135
+
136
+ useEffect(() => {
137
+ if (!dxGrid || !presentation) {
138
+ return;
139
+ }
140
+
141
+ dxGrid.getCells = getCells;
142
+ }, [dxGrid, presentation, getCells]);
143
+
144
+ // Trigger grid update when rows or cell values change.
145
+ useEffect(() => {
146
+ if (!dxGrid) {
147
+ return;
148
+ }
149
+
150
+ dxGrid.updateCells(true);
151
+ dxGrid.requestUpdate();
152
+ }, [dxGrid, rows, cellUpdateCounter]);
153
+
154
+ const handleInsertRowResult = useCallback(
155
+ (insertResult?: InsertRowResult) => {
156
+ if (insertResult === 'draft') {
157
+ requestAnimationFrame(() => {
158
+ dxGrid?.setFocus({ plane: 'frozenRowsEnd', col: 0, row: 0 });
159
+ dxGrid?.refocus();
160
+ });
161
+ } else {
162
+ requestAnimationFrame(() => {
163
+ dxGrid?.setFocus({ plane: 'grid', col: 0, row: model ? model.getRowCount() - 1 : 0 });
164
+ dxGrid?.refocus();
165
+ });
122
166
  }
123
- dxGrid.getCells = getCells;
124
- }, [presentation, dxGrid, getCells]);
125
-
126
- /**
127
- * Provides an external controller that can be called to repaint the table.
128
- */
129
- useImperativeHandle<TableController, TableController>(forwardedRef, () => {
130
- if (!presentation || !dxGrid) {
131
- return {};
167
+ },
168
+ [model, dxGrid],
169
+ );
170
+
171
+ const handleSave = useCallback(() => {
172
+ dxGrid?.updateCells(true);
173
+ dxGrid?.requestUpdate();
174
+ }, [dxGrid]);
175
+
176
+ const handleSaveDraftRow = useCallback(
177
+ (rowIndex = 0) => {
178
+ if (model && dxGrid) {
179
+ const didCommitSuccessfully = model.commitDraftRow(rowIndex);
180
+ if (didCommitSuccessfully) {
181
+ requestAnimationFrame(() => {
182
+ dxGrid.scrollToEndRow();
183
+ });
184
+ }
132
185
  }
186
+ },
187
+ [model, dxGrid],
188
+ );
133
189
 
134
- return {
135
- update: (cell) => {
136
- if (cell) {
137
- dxGrid.updateIfWithinBounds(cell, true);
138
- } else {
139
- dxGrid.updateCells(true);
140
- dxGrid.requestUpdate();
141
- }
142
- },
143
- };
144
- }, [presentation, dxGrid]);
145
-
146
- const handleGridClick = useCallback(
147
- (event: MouseEvent) => {
148
- const rowIndex = safeParseInt((event.target as HTMLElement).closest('[aria-rowindex]')?.ariaRowIndex ?? '');
149
- if (rowIndex != null) {
150
- if (onRowClick) {
190
+ const handleGridClick = useCallback(
191
+ (event: MouseEvent) => {
192
+ const cell = closestCell(event.target as HTMLElement);
193
+ if (cell) {
194
+ const { row: rowIndex, plane } = cell;
195
+ if (onRowClick) {
196
+ if (plane === 'grid') {
151
197
  const row = model?.getRowAt(rowIndex);
152
198
  row && onRowClick(row);
153
- }
154
-
155
- if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
156
- model.selection.toggleSelectionForRowIndex(rowIndex);
199
+ } else {
200
+ onRowClick(cell);
157
201
  }
158
202
  }
159
203
 
160
- if (!modals) {
161
- return;
204
+ if (model?.features.selection.enabled && model?.selection.selectionMode === 'single') {
205
+ model.selection.toggleSelectionForRowIndex(rowIndex);
162
206
  }
207
+ }
163
208
 
164
- const buttonSelector = Object.values(tableButtons)
165
- .map((button) => `button[${button.attr}]`)
166
- .join(',');
209
+ if (!modals) {
210
+ return;
211
+ }
167
212
 
168
- const tableButtonElement = (event.target as HTMLElement).closest(buttonSelector) as HTMLElement;
169
- if (tableButtonElement) {
170
- const button = Object.values(tableButtons).find((btn) => tableButtonElement.hasAttribute(btn.attr));
171
- if (!button) {
172
- return;
173
- }
213
+ const buttonSelector = Object.values(tableButtons)
214
+ .map((button) => `button[${button.attr}]`)
215
+ .join(',');
174
216
 
175
- modals.setTrigger(tableButtonElement);
176
- const data = button.getData(tableButtonElement);
177
- switch (data.type) {
178
- case 'rowMenu': {
179
- modals.showRowMenu(data.rowIndex);
180
- break;
181
- }
182
- case 'columnSettings': {
183
- modals.showColumnMenu(data.fieldId);
184
- break;
185
- }
186
- case 'newColumn': {
187
- modals.showColumnCreator();
188
- break;
189
- }
190
- case 'sort': {
191
- model?.sorting?.toggleSort(data.fieldId);
192
- break;
193
- }
194
- case 'saveDraftRow': {
195
- if (model) {
196
- const didCommitSuccessfully = model.commitDraftRow(data.rowIndex);
197
- if (dxGrid && didCommitSuccessfully) {
198
- requestAnimationFrame(() => {
199
- dxGrid.scrollToEndRow();
200
- });
201
- }
202
- }
203
- break;
204
- }
205
- }
217
+ const tableButtonElement = (event.target as HTMLElement).closest(buttonSelector) as HTMLElement;
218
+ if (tableButtonElement) {
219
+ const button = Object.values(tableButtons).find((btn) => tableButtonElement.hasAttribute(btn.attr));
220
+ if (!button) {
206
221
  return;
207
222
  }
208
223
 
209
- const controlSelector = Object.values(tableControls)
210
- .map((control) => `input[${control.attr}]`)
211
- .join(',');
212
-
213
- const controlElement = (event.target as HTMLElement).closest(controlSelector) as HTMLElement;
214
- if (controlElement) {
215
- const control = Object.values(tableControls).find((ctrl) => controlElement.hasAttribute(ctrl.attr));
216
- if (!control) {
217
- return;
224
+ modals.setTrigger(tableButtonElement);
225
+ const data = button.getData(tableButtonElement);
226
+ switch (data.type) {
227
+ case 'rowMenu': {
228
+ modals.showRowMenu(data.rowIndex);
229
+ break;
218
230
  }
219
-
220
- const data = control.getData(controlElement);
221
- switch (data.type) {
222
- case 'checkbox': {
223
- if (data.header) {
224
- model?.selection.setSelection(model.selection.allRowsSeleted.value ? 'none' : 'all');
225
- } else {
226
- model?.selection.toggleSelectionForRowIndex(data.rowIndex);
227
- }
228
- break;
229
- }
230
- case 'switch': {
231
- if (model) {
232
- model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
233
- }
234
- break;
235
- }
231
+ case 'columnSettings': {
232
+ modals.showColumnMenu(data.fieldId);
233
+ break;
234
+ }
235
+ case 'newColumn': {
236
+ modals.showColumnCreator();
237
+ break;
238
+ }
239
+ case 'sort': {
240
+ model?.toggleSort(data.fieldId);
241
+ break;
242
+ }
243
+ case 'saveDraftRow': {
244
+ handleSaveDraftRow();
245
+ break;
236
246
  }
237
247
  }
238
- },
239
- [model, modals, dxGrid],
240
- );
248
+ return;
249
+ }
241
250
 
242
- const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
243
- (increment, delta) => {
244
- if (dxGrid) {
245
- dxGrid.refocus(increment, delta);
246
- }
247
- },
248
- [dxGrid],
249
- );
251
+ const controlSelector = Object.values(tableControls)
252
+ .map((control) => `input[${control.attr}]`)
253
+ .join(',');
250
254
 
251
- const handleEnter = useCallback<NonNullable<TableCellEditorProps['onEnter']>>(
252
- (cell) => {
253
- if (!model?.features.dataEditable) {
255
+ const controlElement = (event.target as HTMLElement).closest(controlSelector) as HTMLElement;
256
+ if (controlElement) {
257
+ const control = Object.values(tableControls).find((ctrl) => controlElement.hasAttribute(ctrl.attr));
258
+ if (!control) {
254
259
  return;
255
260
  }
256
261
 
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
- });
262
+ const data = control.getData(controlElement);
263
+ switch (data.type) {
264
+ case 'checkbox': {
265
+ if (data.header) {
266
+ model?.selection.setSelection(model.selection.allRowsSelected ? 'none' : 'all');
267
+ } else {
268
+ model?.selection.toggleSelectionForRowIndex(data.rowIndex);
269
+ }
270
+ break;
271
+ }
272
+ case 'switch': {
273
+ if (model) {
274
+ model.updateCellData({ row: data.rowIndex, col: data.colIndex }, (value) => !value);
275
+ }
276
+ break;
265
277
  }
266
278
  }
267
- },
268
- [model, dxGrid],
269
- );
279
+ }
280
+ },
281
+ [model, modals, dxGrid],
282
+ );
270
283
 
271
- const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
272
- (event) => {
273
- if (!model?.features.dataEditable) {
274
- return;
284
+ const handleFocus = useCallback<NonNullable<TableCellEditorProps['onFocus']>>(
285
+ (increment, delta, cell) => {
286
+ if (dxGrid && model) {
287
+ if (cell?.plane === 'grid' && cell?.row >= model.getRowCount() - 1 && increment !== 'col') {
288
+ handleInsertRowResult(draftRowCount < 1 ? model.insertRow() : 'final');
289
+ } else if (cell?.plane === 'frozenRowsEnd' && increment === 'row') {
290
+ handleSaveDraftRow(cell.row);
291
+ handleInsertRowResult(model.insertRow());
292
+ } else {
293
+ dxGrid.refocus(increment, delta);
275
294
  }
295
+ }
296
+ },
297
+ [dxGrid, model],
298
+ );
276
299
 
277
- const cell = closestCell(event.target);
278
- if (!model || !cell) {
279
- return;
280
- }
300
+ const handleKeyDown = useCallback<NonNullable<GridContentProps['onKeyDown']>>(
301
+ (event) => {
302
+ if (!model?.features.dataEditable) {
303
+ return;
304
+ }
305
+
306
+ const cell = closestCell(event.target);
307
+ if (!model || !cell) {
308
+ return;
309
+ }
281
310
 
311
+ // Handle Meta+C (Copy) and Meta+V (Paste) commands
312
+ if (event.metaKey || event.ctrlKey) {
282
313
  switch (event.key) {
283
- case 'Backspace':
284
- case 'Delete': {
314
+ case 'c': {
315
+ // Copy focused cell's text content to clipboard
285
316
  try {
286
- model.setCellData(cell, undefined);
317
+ const cellData = model.getCellData(cell);
318
+ const textContent = cellData?.toString() ?? '';
319
+ void navigator.clipboard.writeText(textContent);
287
320
  event.preventDefault();
288
- } catch {
289
- // Delete results in a validation error; don’t prevent default so dx-grid can emit an edit request.
321
+ } catch (error) {
322
+ log.catch(error);
290
323
  }
291
324
  break;
292
325
  }
326
+ case 'v': {
327
+ // Paste clipboard content to focused cell
328
+ event.preventDefault();
329
+ void navigator.clipboard.readText().then((clipboardText) => {
330
+ try {
331
+ // Attempt to set the cell's content to clipboard content
332
+ model.setCellData(cell, String.trim(clipboardText).replace(/[\n\r]+/, ' '));
333
+ handleSave();
334
+ } catch {
335
+ // If validation fails, emit a DxEditRequest event with initialContent from clipboard
336
+ // TODO(thure): Should `dx-grid` expose a method like this?
337
+ const cellElement = (event.target as HTMLElement).closest(
338
+ '[data-dx-grid-action="cell"]',
339
+ ) as HTMLElement;
340
+ if (cellElement) {
341
+ const rect = cellElement.getBoundingClientRect();
342
+ const editRequest = new DxEditRequest({
343
+ cellIndex: `${cell.plane},${cell.col},${cell.row}`,
344
+ cellBox: {
345
+ insetInlineStart: rect.left,
346
+ insetBlockStart: rect.top,
347
+ inlineSize: rect.width,
348
+ blockSize: rect.height,
349
+ },
350
+ cellElement,
351
+ initialContent: clipboardText,
352
+ });
353
+ cellElement.dispatchEvent(editRequest);
354
+ }
355
+ }
356
+ });
357
+ break;
358
+ }
293
359
  }
294
- },
295
- [model],
296
- );
297
-
298
- const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
299
- (event) => {
300
- if (event.axis === 'col') {
301
- const columnIndex = parseInt(event.index, 10);
302
- model?.setColumnWidth(columnIndex, event.size);
303
- }
304
- },
305
- [model],
306
- );
360
+ }
307
361
 
308
- const handleWheel = useCallback(
309
- (event: WheelEvent) => {
310
- if (!ignoreAttention && !hasAttention) {
311
- event.stopPropagation();
362
+ switch (event.key) {
363
+ case 'Backspace':
364
+ case 'Delete': {
365
+ try {
366
+ model.setCellData(cell, undefined);
367
+ event.preventDefault();
368
+ handleSave();
369
+ } catch {
370
+ // Delete results in a validation error; don’t prevent default so dx-grid can emit an edit request.
371
+ }
372
+ break;
312
373
  }
313
- },
314
- [hasAttention, ignoreAttention],
315
- );
374
+ }
375
+ },
376
+ [model, dxGrid, handleSave],
377
+ );
316
378
 
317
- const handleNewColumn = useCallback(() => {
318
- const columns = model?.getColumnCount();
319
- if (dxGrid && columns) {
320
- dxGrid.scrollToColumn(columns - 1);
379
+ const handleAxisResize = useCallback<NonNullable<GridContentProps['onAxisResize']>>(
380
+ (event) => {
381
+ if (event.axis === 'col') {
382
+ const columnIndex = parseInt(event.index, 10);
383
+ model?.setColumnWidth(columnIndex, event.size);
321
384
  }
322
- }, [model, dxGrid]);
323
-
324
- // TODO(burdon): Factor out?
325
- // TODO(burdon): Generalize to handle other value types (e.g., enums).
326
- const handleQuery = useCallback<NonNullable<TableCellEditorProps['onQuery']>>(
327
- async ({ field, props }, text) => {
328
- if (model && props.referenceSchema && field.referencePath) {
329
- const space = getSpace(model.view);
330
- invariant(space);
331
-
332
- let schema;
333
- if (client) {
334
- schema = client.graph.schemaRegistry.getSchema(props.referenceSchema);
335
- }
336
- if (!schema) {
337
- schema = space.db.schemaRegistry.getSchema(props.referenceSchema);
338
- }
385
+ },
386
+ [model],
387
+ );
339
388
 
340
- if (schema) {
341
- const { objects } = await space.db.query(Filter.type(schema)).run();
342
- const options = objects
343
- .map((obj) => {
344
- const value = getValue(obj, field.referencePath!);
345
- if (!value || typeof value !== 'string') {
346
- return undefined;
347
- }
389
+ const handleWheel = useCallback(
390
+ (event: WheelEvent) => {
391
+ if (!ignoreAttention && !hasAttention) {
392
+ event.stopPropagation();
393
+ }
394
+ },
395
+ [hasAttention, ignoreAttention],
396
+ );
348
397
 
349
- return {
350
- label: value,
351
- data: obj,
352
- };
353
- })
354
- .filter(isNotFalsy);
355
-
356
- return [
357
- ...options,
358
- {
359
- label: t('create new object label', { text }),
360
- data: createOption(text),
361
- },
362
- ];
363
- }
364
- }
398
+ const handleNewColumn = useCallback(() => {
399
+ const columns = model?.getColumnCount();
400
+ if (dxGrid && columns) {
401
+ dxGrid.scrollToColumn(columns - 1);
402
+ }
403
+ }, [model, dxGrid]);
404
+
405
+ /**
406
+ * Provides an external controller that can be called to repaint the table.
407
+ */
408
+ useImperativeHandle<TableController, TableController>(forwardedRef, () => {
409
+ if (!dxGrid || !presentation) {
410
+ return {};
411
+ }
365
412
 
366
- return [];
413
+ return {
414
+ update: (cell) => {
415
+ if (cell) {
416
+ dxGrid.updateIfWithinBounds(cell, true);
417
+ } else {
418
+ dxGrid.updateCells(true);
419
+ dxGrid.requestUpdate();
420
+ }
367
421
  },
368
- [model, client, t],
369
- );
422
+ handleInsertRowResult,
423
+ };
424
+ }, [dxGrid, model, presentation]);
370
425
 
371
- const handleSave = useCallback(() => {
372
- dxGrid?.updateCells(true);
373
- }, [dxGrid]);
426
+ if (!model || !modals) {
427
+ return <span role='none' className='attention-surface' />;
428
+ }
374
429
 
375
- if (!model || !modals) {
376
- return <span role='none' className='attention-surface' />;
377
- }
430
+ return (
431
+ <Grid.Root id={model.id ?? 'table-grid'}>
432
+ <TableValueEditor<T>
433
+ model={model}
434
+ modals={modals}
435
+ schema={schema}
436
+ onFocus={handleFocus}
437
+ onSave={handleSave}
438
+ onCreate={onCreate}
439
+ />
440
+ <Grid.Content
441
+ className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
442
+ frozen={frozen}
443
+ columns={columnMeta}
444
+ columnDefault={columnDefault}
445
+ rowDefault={rowDefault}
446
+ limitRows={rows.length}
447
+ limitColumns={columnCount}
448
+ overscroll='trap'
449
+ onAxisResize={handleAxisResize}
450
+ onClick={handleGridClick}
451
+ onKeyDownCapture={handleKeyDown}
452
+ onWheelCapture={handleWheel}
453
+ {...(testId && { 'data-testid': testId })}
454
+ ref={setDxGrid}
455
+ />
456
+ <RowActionsMenu model={model} modals={modals} />
457
+ <ColumnActionsMenu model={model} modals={modals} />
458
+ <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
459
+ </Grid.Root>
460
+ );
461
+ };
378
462
 
379
- return (
380
- <Grid.Root id={model.id ?? 'table-grid'}>
381
- <TableValueEditor
382
- model={model}
383
- modals={modals}
384
- schema={schema}
385
- onEnter={handleEnter}
386
- onFocus={handleFocus}
387
- onQuery={handleQuery}
388
- onSave={handleSave}
389
- />
390
- <Grid.Content
391
- className={mx('[--dx-grid-base:var(--baseSurface)]', gridSeparatorInlineEnd, gridSeparatorBlockEnd)}
392
- frozen={frozen}
393
- columns={model.columnMeta.value}
394
- limitRows={model.getRowCount() ?? 0}
395
- limitColumns={model.projection.fields.length}
396
- overscroll='trap'
397
- onAxisResize={handleAxisResize}
398
- onClick={handleGridClick}
399
- onKeyDownCapture={handleKeyDown}
400
- onWheelCapture={handleWheel}
401
- ref={setDxGrid}
402
- />
403
- <RowActionsMenu model={model} modals={modals} />
404
- <ColumnActionsMenu model={model} modals={modals} />
405
- <ColumnSettings model={model} modals={modals} onNewColumn={handleNewColumn} />
406
- <CreateRefPanel model={model} modals={modals} />
407
- </Grid.Root>
408
- );
409
- },
410
- );
463
+ const TableMain = forwardRef(TableMainInner) as <T extends Type.Entity.Any = Type.Entity.Any>(
464
+ props: TableMainProps<T> & { ref?: Ref<TableController> },
465
+ ) => JSX.Element;
411
466
 
412
467
  export const Table = {
413
468
  Root: TableRoot,