@dxos/react-ui-table 0.9.1-main.c7dcc2e112 → 0.9.1-staging.ee54ba693a

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 (68) hide show
  1. package/dist/lib/browser/{chunk-5V5FV3GS.mjs → chunk-K4J2CKTZ.mjs} +66 -14
  2. package/dist/lib/browser/chunk-K4J2CKTZ.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-62PYOJPY.mjs → chunk-VV3P3ADH.mjs} +5 -5
  4. package/dist/lib/browser/chunk-VV3P3ADH.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +76 -10
  6. package/dist/lib/browser/index.mjs.map +3 -3
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +2 -2
  9. package/dist/lib/browser/translations.mjs +5 -2
  10. package/dist/lib/browser/translations.mjs.map +2 -2
  11. package/dist/lib/browser/types/index.mjs +1 -1
  12. package/dist/lib/node-esm/{chunk-5H6GHWNR.mjs → chunk-E6V274ZW.mjs} +66 -14
  13. package/dist/lib/node-esm/chunk-E6V274ZW.mjs.map +7 -0
  14. package/dist/lib/node-esm/{chunk-7LMOZUTS.mjs → chunk-RPEWNOZZ.mjs} +5 -5
  15. package/dist/lib/node-esm/chunk-RPEWNOZZ.mjs.map +7 -0
  16. package/dist/lib/node-esm/index.mjs +76 -10
  17. package/dist/lib/node-esm/index.mjs.map +3 -3
  18. package/dist/lib/node-esm/meta.json +1 -1
  19. package/dist/lib/node-esm/testing/index.mjs +2 -2
  20. package/dist/lib/node-esm/translations.mjs +5 -2
  21. package/dist/lib/node-esm/translations.mjs.map +2 -2
  22. package/dist/lib/node-esm/types/index.mjs +1 -1
  23. package/dist/types/src/components/Table/DynamicTable.stories.d.ts +4 -1
  24. package/dist/types/src/components/Table/DynamicTable.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/Table/Relations.stories.d.ts +4 -1
  26. package/dist/types/src/components/Table/Relations.stories.d.ts.map +1 -1
  27. package/dist/types/src/components/Table/Table.d.ts +1 -0
  28. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  29. package/dist/types/src/components/Table/Table.stories.d.ts +6 -3
  30. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Table/TableToolbar.d.ts +3 -1
  32. package/dist/types/src/components/Table/TableToolbar.d.ts.map +1 -1
  33. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts +7 -4
  34. package/dist/types/src/components/TableCellEditor/TableCellEditor.stories.d.ts.map +1 -1
  35. package/dist/types/src/components/testing.d.ts +2 -3
  36. package/dist/types/src/components/testing.d.ts.map +1 -1
  37. package/dist/types/src/hooks/useTableModel.d.ts.map +1 -1
  38. package/dist/types/src/model/index.d.ts +1 -0
  39. package/dist/types/src/model/index.d.ts.map +1 -1
  40. package/dist/types/src/model/table-model.d.ts +15 -8
  41. package/dist/types/src/model/table-model.d.ts.map +1 -1
  42. package/dist/types/src/model/table-presentation.d.ts.map +1 -1
  43. package/dist/types/src/model/table-view-state.d.ts +19 -0
  44. package/dist/types/src/model/table-view-state.d.ts.map +1 -0
  45. package/dist/types/src/translations.d.ts +4 -1
  46. package/dist/types/src/translations.d.ts.map +1 -1
  47. package/dist/types/src/types/Table.d.ts +3 -11
  48. package/dist/types/src/types/Table.d.ts.map +1 -1
  49. package/dist/types/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +43 -44
  51. package/src/components/Table/Table.stories.tsx +3 -3
  52. package/src/components/Table/Table.tsx +1 -0
  53. package/src/components/Table/TableToolbar.tsx +38 -5
  54. package/src/components/TableCellEditor/TableCellEditor.stories.tsx +2 -2
  55. package/src/components/testing.ts +8 -6
  56. package/src/hooks/useTableModel.ts +4 -2
  57. package/src/model/index.ts +1 -0
  58. package/src/model/table-model.test.ts +93 -5
  59. package/src/model/table-model.ts +36 -20
  60. package/src/model/table-presentation.test.ts +6 -4
  61. package/src/model/table-presentation.ts +30 -0
  62. package/src/model/table-view-state.ts +30 -0
  63. package/src/translations.ts +4 -1
  64. package/src/types/Table.ts +17 -19
  65. package/dist/lib/browser/chunk-5V5FV3GS.mjs.map +0 -7
  66. package/dist/lib/browser/chunk-62PYOJPY.mjs.map +0 -7
  67. package/dist/lib/node-esm/chunk-5H6GHWNR.mjs.map +0 -7
  68. package/dist/lib/node-esm/chunk-7LMOZUTS.mjs.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-table",
3
- "version": "0.9.1-main.c7dcc2e112",
3
+ "version": "0.9.1-staging.ee54ba693a",
4
4
  "description": "A table component.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -58,58 +58,57 @@
58
58
  "@radix-ui/react-use-controllable-state": "1.1.0",
59
59
  "date-fns": "^3.6.0",
60
60
  "lodash.orderby": "^4.6.0",
61
- "@dxos/async": "0.9.1-main.c7dcc2e112",
62
- "@dxos/echo": "0.9.1-main.c7dcc2e112",
63
- "@dxos/debug": "0.9.1-main.c7dcc2e112",
64
- "@dxos/effect": "0.9.1-main.c7dcc2e112",
65
- "@dxos/context": "0.9.1-main.c7dcc2e112",
66
- "@dxos/invariant": "0.9.1-main.c7dcc2e112",
67
- "@dxos/log": "0.9.1-main.c7dcc2e112",
68
- "@dxos/random": "0.9.1-main.c7dcc2e112",
69
- "@dxos/keys": "0.9.1-main.c7dcc2e112",
70
- "@dxos/lit-grid": "0.9.1-main.c7dcc2e112",
71
- "@dxos/react-ui-attention": "0.9.1-main.c7dcc2e112",
72
- "@dxos/react-ui-components": "0.9.1-main.c7dcc2e112",
73
- "@dxos/react-ui-editor": "0.9.1-main.c7dcc2e112",
74
- "@dxos/react-ui-form": "0.9.1-main.c7dcc2e112",
75
- "@dxos/react-ui-grid": "0.9.1-main.c7dcc2e112",
76
- "@dxos/react-ui-list": "0.9.1-main.c7dcc2e112",
77
- "@dxos/react-ui-menu": "0.9.1-main.c7dcc2e112",
78
- "@dxos/react-client": "0.9.1-main.c7dcc2e112",
79
- "@dxos/react-ui-search": "0.9.1-main.c7dcc2e112",
80
- "@dxos/react-ui-stack": "0.9.1-main.c7dcc2e112",
81
- "@dxos/schema": "0.9.1-main.c7dcc2e112",
82
- "@dxos/react-ui-syntax-highlighter": "0.9.1-main.c7dcc2e112",
83
- "@dxos/types": "0.9.1-main.c7dcc2e112",
84
- "@dxos/ui-types": "0.9.1-main.c7dcc2e112",
85
- "@dxos/util": "0.9.1-main.c7dcc2e112"
61
+ "@dxos/context": "0.9.1-staging.ee54ba693a",
62
+ "@dxos/async": "0.9.1-staging.ee54ba693a",
63
+ "@dxos/debug": "0.9.1-staging.ee54ba693a",
64
+ "@dxos/echo": "0.9.1-staging.ee54ba693a",
65
+ "@dxos/effect": "0.9.1-staging.ee54ba693a",
66
+ "@dxos/keys": "0.9.1-staging.ee54ba693a",
67
+ "@dxos/lit-grid": "0.9.1-staging.ee54ba693a",
68
+ "@dxos/invariant": "0.9.1-staging.ee54ba693a",
69
+ "@dxos/log": "0.9.1-staging.ee54ba693a",
70
+ "@dxos/random": "0.9.1-staging.ee54ba693a",
71
+ "@dxos/react-client": "0.9.1-staging.ee54ba693a",
72
+ "@dxos/react-ui-attention": "0.9.1-staging.ee54ba693a",
73
+ "@dxos/react-ui-components": "0.9.1-staging.ee54ba693a",
74
+ "@dxos/react-ui-editor": "0.9.1-staging.ee54ba693a",
75
+ "@dxos/react-ui-form": "0.9.1-staging.ee54ba693a",
76
+ "@dxos/react-ui-grid": "0.9.1-staging.ee54ba693a",
77
+ "@dxos/react-ui-menu": "0.9.1-staging.ee54ba693a",
78
+ "@dxos/react-ui-list": "0.9.1-staging.ee54ba693a",
79
+ "@dxos/react-ui-search": "0.9.1-staging.ee54ba693a",
80
+ "@dxos/schema": "0.9.1-staging.ee54ba693a",
81
+ "@dxos/react-ui-syntax-highlighter": "0.9.1-staging.ee54ba693a",
82
+ "@dxos/types": "0.9.1-staging.ee54ba693a",
83
+ "@dxos/ui-types": "0.9.1-staging.ee54ba693a",
84
+ "@dxos/util": "0.9.1-staging.ee54ba693a"
86
85
  },
87
86
  "devDependencies": {
88
- "@effect/platform": "0.96.1",
87
+ "@effect/platform": "0.96.2",
89
88
  "@types/lodash.orderby": "^4.6.9",
90
- "@types/react": "~19.2.7",
89
+ "@types/react": "~19.2.17",
91
90
  "@types/react-dom": "~19.2.3",
92
- "effect": "3.21.3",
91
+ "effect": "3.21.4",
93
92
  "fast-check": "^3.19.0",
94
- "react": "~19.2.3",
95
- "react-dom": "~19.2.3",
93
+ "react": "~19.2.7",
94
+ "react-dom": "~19.2.7",
96
95
  "vite": "^8.0.16",
97
- "@dxos/lit-ui": "0.9.1-main.c7dcc2e112",
98
- "@dxos/random": "0.9.1-main.c7dcc2e112",
99
- "@dxos/react-client": "0.9.1-main.c7dcc2e112",
100
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
101
- "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112",
102
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
103
- "@dxos/test-utils": "0.9.1-main.c7dcc2e112"
96
+ "@dxos/lit-ui": "0.9.1-staging.ee54ba693a",
97
+ "@dxos/react-client": "0.9.1-staging.ee54ba693a",
98
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
99
+ "@dxos/random": "0.9.1-staging.ee54ba693a",
100
+ "@dxos/test-utils": "0.9.1-staging.ee54ba693a",
101
+ "@dxos/storybook-utils": "0.9.1-staging.ee54ba693a",
102
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
104
103
  },
105
104
  "peerDependencies": {
106
105
  "@effect-atom/atom-react": "^0.5.0",
107
- "@effect/platform": "0.96.1",
108
- "effect": "3.21.3",
109
- "react": "~19.2.3",
110
- "react-dom": "~19.2.3",
111
- "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
112
- "@dxos/react-ui": "0.9.1-main.c7dcc2e112"
106
+ "@effect/platform": "0.96.2",
107
+ "effect": "3.21.4",
108
+ "react": "~19.2.7",
109
+ "react-dom": "~19.2.7",
110
+ "@dxos/react-ui": "0.9.1-staging.ee54ba693a",
111
+ "@dxos/ui-theme": "0.9.1-staging.ee54ba693a"
113
112
  },
114
113
  "publishConfig": {
115
114
  "access": "public"
@@ -7,7 +7,7 @@ import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback } from 'react';
8
8
  import { expect, userEvent, waitFor, within } from 'storybook/test';
9
9
 
10
- import { DXN, Annotation, type Database, Format, Obj, type QueryAST, Ref, Type, View } from '@dxos/echo';
10
+ import { Annotation, type Database, DXN, Format, Obj, type QueryAST, Ref, Type, View } from '@dxos/echo';
11
11
  import { type Mutable, PropertyMetaAnnotationId } from '@dxos/echo/internal';
12
12
  import { invariant } from '@dxos/invariant';
13
13
  import { random } from '@dxos/random';
@@ -150,7 +150,7 @@ const DefaultStory = () => {
150
150
  );
151
151
  };
152
152
 
153
- type DefaultStoryProps = { rows?: number };
153
+ type StoryArgs = { rows?: number };
154
154
 
155
155
  //
156
156
  // Story definitions.
@@ -338,7 +338,7 @@ export const RequiredSchema: StoryObj = {
338
338
  },
339
339
  };
340
340
 
341
- export const Tags: Meta<DefaultStoryProps> = {
341
+ export const Tags: Meta<StoryArgs> = {
342
342
  title: 'ui/react-ui-table/Table',
343
343
  render: DefaultStory,
344
344
  decorators: [
@@ -72,5 +72,6 @@ export const Table = {
72
72
  };
73
73
 
74
74
  export type { TableController, TableRootProps };
75
+ export type { TableExportFormat } from './TableToolbar';
75
76
 
76
77
  export { useTableContext };
@@ -15,6 +15,7 @@ import {
15
15
  MenuRootProps,
16
16
  createGapSeparator,
17
17
  createMenuAction,
18
+ createMenuItemGroup,
18
19
  useMenuActions,
19
20
  } from '@dxos/react-ui-menu';
20
21
 
@@ -24,7 +25,9 @@ export type TableToolbarActionProperties = { type: TableToolbarActionType };
24
25
 
25
26
  export type TableToolbarAction = MenuAction<TableToolbarActionProperties>;
26
27
 
27
- export type TableToolbarActionType = 'add-row' | 'comment' | 'save-view';
28
+ export type TableToolbarActionType = 'add-row' | 'comment' | 'save-view' | 'export-csv' | 'export-json' | 'export-xml';
29
+
30
+ export type TableExportFormat = 'csv' | 'json' | 'xml';
28
31
 
29
32
  type TableToolbarState = Partial<{ viewDirty: boolean }>;
30
33
 
@@ -32,15 +35,18 @@ const createTableToolbarActions = ({
32
35
  stateAtom,
33
36
  onAdd,
34
37
  onSave,
38
+ onExport,
35
39
  customActions,
36
40
  }: {
37
41
  stateAtom: Atom.Atom<TableToolbarState>;
38
42
  onAdd?: () => void;
39
43
  onSave?: () => void;
44
+ onExport?: (format: TableExportFormat) => void;
40
45
  customActions?: Atom.Atom<ActionGraphProps>;
41
46
  }) =>
42
47
  Atom.make((get) => {
43
48
  const nodes: ActionGraphNodes = [];
49
+ const edges: ActionGraphEdges = [];
44
50
  if (onAdd) {
45
51
  const add = createMenuAction<TableToolbarActionProperties>('add-row', onAdd, {
46
52
  type: 'add-row' as const,
@@ -49,6 +55,7 @@ const createTableToolbarActions = ({
49
55
  testId: 'table.toolbar.add-row',
50
56
  });
51
57
  nodes.push(add);
58
+ edges.push({ source: 'root', target: add.id, relation: 'child' });
52
59
  }
53
60
  if (onSave) {
54
61
  const state = get(stateAtom);
@@ -61,10 +68,35 @@ const createTableToolbarActions = ({
61
68
  hidden: !state.viewDirty,
62
69
  });
63
70
  nodes.push(save);
71
+ edges.push({ source: 'root', target: save.id, relation: 'child' });
72
+ }
73
+ if (onExport) {
74
+ const exportGroup = createMenuItemGroup('export-rows', {
75
+ label: ['export-rows.menu', { ns: translationKey }],
76
+ icon: 'ph--export--regular',
77
+ iconOnly: true,
78
+ variant: 'dropdownMenu',
79
+ });
80
+ nodes.push(exportGroup);
81
+ edges.push({ source: 'root', target: exportGroup.id, relation: 'child' });
82
+
83
+ const formats: Array<{ format: TableExportFormat; type: TableToolbarActionType; label: string }> = [
84
+ { format: 'csv', type: 'export-csv', label: 'export-rows-csv.menu' },
85
+ { format: 'json', type: 'export-json', label: 'export-rows-json.menu' },
86
+ { format: 'xml', type: 'export-xml', label: 'export-rows-xml.menu' },
87
+ ];
88
+ for (const { format, type, label } of formats) {
89
+ const action = createMenuAction<TableToolbarActionProperties>(type, () => onExport(format), {
90
+ type,
91
+ label: [label, { ns: translationKey }],
92
+ });
93
+ nodes.push(action);
94
+ edges.push({ source: exportGroup.id, target: action.id, relation: 'child' });
95
+ }
64
96
  }
65
97
  const gap = createGapSeparator();
66
98
  nodes.push(...gap.nodes);
67
- const edges: ActionGraphEdges = nodes.map(({ id: target }) => ({ source: 'root', target, relation: 'child' }));
99
+ edges.push(...gap.edges);
68
100
  if (customActions) {
69
101
  const custom = get(customActions);
70
102
  nodes.push(...custom.nodes);
@@ -81,11 +113,12 @@ export type TableToolbarProps = Pick<MenuRootProps, 'attendableId'> &
81
113
  TableToolbarState & {
82
114
  onAdd?: () => void;
83
115
  onSave?: () => void;
116
+ onExport?: (format: TableExportFormat) => void;
84
117
  customActions?: Atom.Atom<ActionGraphProps>;
85
118
  };
86
119
 
87
120
  export const TableToolbar = composable<HTMLDivElement, TableToolbarProps>(
88
- ({ attendableId, viewDirty, onAdd, onSave, customActions, ...props }, forwardedRef) => {
121
+ ({ attendableId, viewDirty, onAdd, onSave, onExport, customActions, ...props }, forwardedRef) => {
89
122
  const registry = useContext(RegistryContext);
90
123
  const stateAtom = useMemo(() => Atom.make<TableToolbarState>({ viewDirty }), []);
91
124
 
@@ -95,8 +128,8 @@ export const TableToolbar = composable<HTMLDivElement, TableToolbarProps>(
95
128
  }, [registry, stateAtom, viewDirty]);
96
129
 
97
130
  const actionsCreator = useMemo(
98
- () => createTableToolbarActions({ stateAtom, onAdd, onSave, customActions }),
99
- [stateAtom, onAdd, onSave, customActions],
131
+ () => createTableToolbarActions({ stateAtom, onAdd, onSave, onExport, customActions }),
132
+ [stateAtom, onAdd, onSave, onExport, customActions],
100
133
  );
101
134
  const menuActions = useMenuActions(actionsCreator);
102
135
 
@@ -19,12 +19,12 @@ import { useTestTableModel } from '../../testing';
19
19
  import { Table } from '../../types';
20
20
  import { TableCellEditor } from './TableCellEditor';
21
21
 
22
- type DefaultStoryProps = {
22
+ type StoryArgs = {
23
23
  editing: GridEditing;
24
24
  };
25
25
 
26
26
  // TODO(burdon): Broken layout.
27
- const DefaultStory = ({ editing }: DefaultStoryProps) => {
27
+ const DefaultStory = ({ editing }: StoryArgs) => {
28
28
  const { model, table } = useTestTableModel();
29
29
 
30
30
  if (!model || !table) {
@@ -12,12 +12,14 @@ import { SchemaEx } from '@dxos/effect';
12
12
  import { random } from '@dxos/random';
13
13
  import { type ProjectionModel } from '@dxos/schema';
14
14
 
15
- export const TestSchema = Schema.Struct({
16
- name: Schema.optional(Schema.String),
17
- age: Schema.optional(Schema.Number),
18
- active: Schema.optional(Schema.Boolean),
19
- netWorth: Schema.optional(Schema.Number),
20
- }).pipe(Type.makeObject(DXN.make('com.example.type.test', '0.1.0')));
15
+ export const TestSchema = Type.makeObject(DXN.make('com.example.type.test', '0.1.0'))(
16
+ Schema.Struct({
17
+ name: Schema.optional(Schema.String),
18
+ age: Schema.optional(Schema.Number),
19
+ active: Schema.optional(Schema.Boolean),
20
+ netWorth: Schema.optional(Schema.Number),
21
+ }),
22
+ );
21
23
 
22
24
  export type TestItem = {
23
25
  name: string;
@@ -6,7 +6,7 @@ import { RegistryContext } from '@effect-atom/atom-react';
6
6
  import { useContext, useEffect, useMemo, useState } from 'react';
7
7
 
8
8
  import { type Database, Obj } from '@dxos/echo';
9
- import { useSelection, useSelectionActions } from '@dxos/react-ui-attention';
9
+ import { useSelection, useSelectionActions, useViewStateManagerOptional } from '@dxos/react-ui-attention';
10
10
  import { type ProjectionModel } from '@dxos/schema';
11
11
 
12
12
  import {
@@ -43,6 +43,7 @@ export const useTableModel = <T extends TableRow = TableRow>({
43
43
  ...props
44
44
  }: UseTableModelProps<T>): TableModel<T> | undefined => {
45
45
  const registry = useContext(RegistryContext);
46
+ const viewState = useViewStateManagerOptional();
46
47
  const selected = useSelection(object && Obj.getURI(object), 'multi');
47
48
  const initialSelection = useMemo(() => selected, [object]);
48
49
 
@@ -59,6 +60,7 @@ export const useTableModel = <T extends TableRow = TableRow>({
59
60
  object,
60
61
  projection,
61
62
  db,
63
+ viewState,
62
64
  change: createEchoChangeCallback<T>(object),
63
65
  features,
64
66
  rowActions,
@@ -75,7 +77,7 @@ export const useTableModel = <T extends TableRow = TableRow>({
75
77
  void model?.close();
76
78
  };
77
79
  // TODO(burdon): Trigger if callbacks change?
78
- }, [registry, object, projection, features, rowActions, initialSelection]);
80
+ }, [registry, viewState, object, projection, features, rowActions, initialSelection]);
79
81
 
80
82
  // Update data when rows change.
81
83
  useEffect(() => {
@@ -3,6 +3,7 @@
3
3
  //
4
4
 
5
5
  export * from './table-model';
6
+ export * from './table-view-state';
6
7
  export * from './modal-controller';
7
8
  export * from './table-presentation';
8
9
  export type { SelectionMode } from './selection-model';
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
8
8
 
9
9
  import { DXN, Filter, JsonSchema, Query, Type } from '@dxos/echo';
10
10
  import { createEchoSchema } from '@dxos/echo/testing';
11
+ import { LocalBackend, MemoryBackend, ViewStateManager } from '@dxos/react-ui-attention';
11
12
  import { ProjectionModel, ViewModel, createDirectChangeCallback } from '@dxos/schema';
12
13
 
13
14
  import { Table } from '../types';
@@ -115,12 +116,94 @@ describe('TableModel', () => {
115
116
  });
116
117
  });
117
118
 
118
- const Test = Schema.Struct({
119
- title: Schema.String,
120
- completed: Schema.Boolean,
121
- }).pipe(Type.makeObject(DXN.make('com.example.type.test', '0.1.0')));
119
+ describe('TableModel sort view state', () => {
120
+ // Share `storage` (and the table object) across instances; a fresh registry/manager/model
121
+ // simulates a reload, proving the sort is externalized to the `local` view state keyed by the
122
+ // table URI rather than held per-model.
123
+ const disposables: LocalBackend[] = [];
124
+ afterEach(() => {
125
+ disposables.splice(0).forEach((backend) => backend.dispose());
126
+ });
122
127
 
123
- const createTableModel = (registry: Registry.Registry, props: Partial<TableModelProps> = {}): TableModel => {
128
+ test('column sort persists to the local view state and is restored after reload', async () => {
129
+ const storage = fakeStorage();
130
+ const schema = createEchoSchema(Type.getSchema(Test));
131
+ const view = ViewModel.make({ query: Query.select(Filter.type(schema)), jsonSchema: schema.jsonSchema });
132
+ const object = Table.make({ view });
133
+
134
+ // Each reopen builds a fresh registry/manager/projection over the SAME table object (and its view),
135
+ // so it exercises a real reload rather than reusing projection state across instances.
136
+ const reopen = async (): Promise<TableModel> => {
137
+ const registry = Registry.make();
138
+ const local = new LocalBackend({ registry, storage });
139
+ disposables.push(local);
140
+ const viewState = new ViewStateManager({ registry, backends: { memory: new MemoryBackend(), local } });
141
+ const projection = new ProjectionModel({
142
+ registry,
143
+ view,
144
+ baseSchema: schema.jsonSchema,
145
+ change: createDirectChangeCallback(view.projection, JsonSchema.toJsonSchema(Type.getSchema(schema))),
146
+ });
147
+ projection.normalizeView();
148
+ const model = new TableModel({
149
+ registry,
150
+ object,
151
+ projection,
152
+ viewState,
153
+ change: createTableDirectChangeCallback(object),
154
+ });
155
+ await model.open();
156
+ return model;
157
+ };
158
+
159
+ const first = await reopen();
160
+ const titleFieldId = first.projection.getFields().find((field) => field.path === 'title')!.id;
161
+ first.setSort(titleFieldId, 'asc');
162
+ expect(first.getSorting()).toEqual({ fieldId: titleFieldId, direction: 'asc' });
163
+ expect(JSON.parse(storage.getItem(`dxos:view-state:table-sort:${first.id}`)!)).toEqual({
164
+ fieldId: titleFieldId,
165
+ direction: 'asc',
166
+ });
167
+ await first.close();
168
+
169
+ // Reload: fresh registry/manager/projection over the same storage + table object.
170
+ const second = await reopen();
171
+ expect(second.getSorting()).toEqual({ fieldId: titleFieldId, direction: 'asc' });
172
+
173
+ second.clearSort();
174
+ expect(second.getSorting()).toBeUndefined();
175
+ await second.close();
176
+
177
+ // The cleared sort is also persisted: a further reload reads no sort.
178
+ const third = await reopen();
179
+ expect(third.getSorting()).toBeUndefined();
180
+ await third.close();
181
+ });
182
+ });
183
+
184
+ // Minimal in-memory Storage stand-in (no real localStorage in the test runner).
185
+ const fakeStorage = (): Storage => {
186
+ const map = new Map<string, string>();
187
+ return {
188
+ get length() {
189
+ return map.size;
190
+ },
191
+ clear: () => map.clear(),
192
+ getItem: (key) => (map.has(key) ? map.get(key)! : null),
193
+ key: (index) => [...map.keys()][index] ?? null,
194
+ removeItem: (key) => void map.delete(key),
195
+ setItem: (key, value) => void map.set(key, value),
196
+ };
197
+ };
198
+
199
+ const Test = Type.makeObject(DXN.make('com.example.type.test', '0.1.0'))(
200
+ Schema.Struct({
201
+ title: Schema.String,
202
+ completed: Schema.Boolean,
203
+ }),
204
+ );
205
+
206
+ const createTableInputs = (registry: Registry.Registry): { object: Table.Table; projection: ProjectionModel } => {
124
207
  const schema = createEchoSchema(Type.getSchema(Test));
125
208
  const view = ViewModel.make({
126
209
  query: Query.select(Filter.type(schema)),
@@ -134,6 +217,11 @@ const createTableModel = (registry: Registry.Registry, props: Partial<TableModel
134
217
  change: createDirectChangeCallback(view.projection, JsonSchema.toJsonSchema(Type.getSchema(schema))),
135
218
  });
136
219
  projection.normalizeView();
220
+ return { object, projection };
221
+ };
222
+
223
+ const createTableModel = (registry: Registry.Registry, props: Partial<TableModelProps> = {}): TableModel => {
224
+ const { object, projection } = createTableInputs(registry);
137
225
  return new TableModel({
138
226
  registry,
139
227
  object,
@@ -7,11 +7,12 @@ import { Atom, type Registry } from '@effect-atom/atom-react';
7
7
  import { Resource } from '@dxos/context';
8
8
  import { type Database, Format, Obj, Order, Query, type QueryAST, Ref, Type, type View } from '@dxos/echo';
9
9
  import { type JsonSchema as JsonSchemaType, toEffectSchema } from '@dxos/echo/JsonSchema';
10
- import { getSnapshot, type Mutable } from '@dxos/echo/Obj';
10
+ import { type Mutable, getSnapshot } from '@dxos/echo/Obj';
11
11
  import { SchemaEx } from '@dxos/effect';
12
12
  import { invariant } from '@dxos/invariant';
13
13
  import { EntityId } from '@dxos/keys';
14
14
  import { type Label } from '@dxos/react-ui';
15
+ import { type ViewStateManager } from '@dxos/react-ui-attention';
15
16
  import { formatForEditing, parseValue } from '@dxos/react-ui-form';
16
17
  import {
17
18
  type DxGridAxisMeta,
@@ -25,16 +26,8 @@ import { type Table } from '../types';
25
26
  import { extractOrder } from '../util';
26
27
  import { compareValues } from '../util/sort';
27
28
  import { extractTagIds } from '../util/tag';
28
-
29
- /**
30
- * Field sort configuration.
31
- */
32
- export type FieldSortType = {
33
- fieldId: string;
34
- direction: QueryAST.OrderDirection;
35
- };
36
-
37
29
  import { type SelectionMode, SelectionModel } from './selection-model';
30
+ import { type FieldSortType, tableSortAspect } from './table-view-state';
38
31
 
39
32
  /**
40
33
  * Callback type for wrapping mutations in Obj.update().
@@ -110,6 +103,12 @@ export type TableModelProps<T extends TableRow = TableRow> = {
110
103
  object: Table.Table;
111
104
  projection: ProjectionModel;
112
105
  db?: Database.Database;
106
+ /**
107
+ * Per-context UI state manager (from `@dxos/react-ui-attention`). When provided, the column sort is
108
+ * stored in the `tableSortAspect` view state keyed by the table URI; otherwise it falls back to an
109
+ * ephemeral atom (e.g. isolated stories/tests with no `ViewStateProvider`).
110
+ */
111
+ viewState?: ViewStateManager;
113
112
  /**
114
113
  * Callbacks to wrap mutations in Obj.update().
115
114
  * Use createEchoChangeCallback() for ECHO-backed objects or createDirectChangeCallback() for plain objects.
@@ -148,7 +147,10 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
148
147
 
149
148
  private readonly _rows: Atom.Writable<T[]>;
150
149
  private readonly _draftRows: Atom.Writable<DraftRow<T>[]>;
151
- // In-memory sort state - changes are local until saved.
150
+ // Optional per-context UI state manager; when present the column sort is view-state-backed.
151
+ private readonly _viewState?: ViewStateManager;
152
+ // Per-user sort state - backed by the `tableSortAspect` view state (keyed by table URI) when a
153
+ // ViewStateManager is provided, else an ephemeral atom. Changes are local until saved to the view.
152
154
  private readonly _inMemorySort: Atom.Writable<FieldSortType | undefined>;
153
155
  // Derived atom for persisted sort from view.query.ast.
154
156
  private readonly _persistedSort: Atom.Atom<FieldSortType | undefined>;
@@ -170,6 +172,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
170
172
  object,
171
173
  projection,
172
174
  db,
175
+ viewState,
173
176
  change,
174
177
  features = {},
175
178
  initialSelection = [],
@@ -185,6 +188,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
185
188
  super();
186
189
  this._registry = registry;
187
190
  this._object = object;
191
+ this._viewState = viewState;
188
192
  this._projection = projection;
189
193
  this._projection.normalizeView();
190
194
  this._db = db;
@@ -205,7 +209,11 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
205
209
  });
206
210
  this._rows = Atom.make<T[]>([]);
207
211
  this._draftRows = Atom.make<DraftRow<T>[]>([]);
208
- this._inMemorySort = Atom.make<FieldSortType | undefined>(undefined);
212
+ // View-state-backed atom survives remount/reload (local backend, keyed by table URI); the
213
+ // ephemeral fallback preserves prior behaviour when no ViewStateManager is supplied.
214
+ this._inMemorySort = this._viewState
215
+ ? this._viewState.atom(tableSortAspect, this.id)
216
+ : Atom.make<FieldSortType | undefined>(undefined);
209
217
  this._cellUpdateCounter = Atom.make<number>(0);
210
218
 
211
219
  // Create derived atom for persisted sort from view.query.ast.
@@ -854,11 +862,19 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
854
862
  return;
855
863
  }
856
864
 
857
- // Update in-memory sort (local changes).
858
- this._registry.set(this._inMemorySort, {
859
- fieldId,
860
- direction,
861
- });
865
+ this._writeSort({ fieldId, direction });
866
+ }
867
+
868
+ /**
869
+ * Writes the per-user sort, routing through the ViewStateManager (so the `local` backend persists)
870
+ * when one is configured, otherwise updating the ephemeral atom directly.
871
+ */
872
+ private _writeSort(value: FieldSortType | undefined): void {
873
+ if (this._viewState) {
874
+ this._viewState.set(tableSortAspect, this.id, value);
875
+ } else {
876
+ this._registry.set(this._inMemorySort, value);
877
+ }
862
878
  }
863
879
 
864
880
  /**
@@ -879,7 +895,7 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
879
895
  * Clears the in-memory sort order.
880
896
  */
881
897
  public clearSort(): void {
882
- this._registry.set(this._inMemorySort, undefined);
898
+ this._writeSort(undefined);
883
899
  }
884
900
 
885
901
  /**
@@ -912,8 +928,8 @@ export class TableModel<T extends TableRow = TableRow> extends Resource {
912
928
  });
913
929
  }
914
930
 
915
- // Clear in-memory sort since it's now persisted.
916
- this._registry.set(this._inMemorySort, undefined);
931
+ // Clear per-user sort since it's now persisted to the view.
932
+ this._writeSort(undefined);
917
933
  }
918
934
 
919
935
  /**
@@ -86,10 +86,12 @@ describe('TablePresentation', () => {
86
86
  });
87
87
  });
88
88
 
89
- const Test = Schema.Struct({
90
- title: Schema.String,
91
- count: Schema.Number,
92
- }).pipe(Type.makeObject(DXN.make('com.example.type.test', '0.1.0')));
89
+ const Test = Type.makeObject(DXN.make('com.example.type.test', '0.1.0'))(
90
+ Schema.Struct({
91
+ title: Schema.String,
92
+ count: Schema.Number,
93
+ }),
94
+ );
93
95
 
94
96
  const createTableModel = (registry: Registry.Registry, props: Partial<TableModelProps> = {}): TableModel => {
95
97
  const schema = createEchoSchema(Type.getSchema(Test));
@@ -214,6 +214,15 @@ export class TablePresentation<T extends TableRow = TableRow> {
214
214
  }
215
215
  }
216
216
 
217
+ // URLs.
218
+ if (props.format === Format.TypeFormat.URL) {
219
+ const value = SchemaEx.getValue(obj, field.path);
220
+ const href = typeof value === 'string' ? safeHttpUrl(value) : undefined;
221
+ if (href) {
222
+ cell.accessoryHtml = `<div role="none" class="absolute end-0 inset-y-0 p-(--dx-grid-cell-content-padding-block)"><a href="${escapeHtmlAttribute(href)}" target="_blank" rel="noopener noreferrer" class="dx-button w-6 aspect-square min-h-0" data-dx-grid-action="accessory"><dx-icon icon="ph--arrow-square-out--regular"/></a></div>`;
223
+ }
224
+ }
225
+
217
226
  // Booleans.
218
227
  if (props.format === Format.TypeFormat.Boolean) {
219
228
  const value = SchemaEx.getValue(obj, field.path);
@@ -462,6 +471,27 @@ export class TablePresentation<T extends TableRow = TableRow> {
462
471
  }
463
472
  }
464
473
 
474
+ /**
475
+ * Parses a cell value as an HTTP(S) URL, returning the normalized href or `undefined`.
476
+ * Restricting to http/https keeps `javascript:`/`data:` URLs out of the rendered anchor.
477
+ */
478
+ const safeHttpUrl = (value: string): string | undefined => {
479
+ try {
480
+ const normalized = /^https?:\/\//i.test(value) ? value : `https://${value}`;
481
+ const url = new URL(normalized);
482
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined;
483
+ } catch {
484
+ return undefined;
485
+ }
486
+ };
487
+
488
+ /**
489
+ * Escapes a string for safe interpolation into a double-quoted HTML attribute, since
490
+ * `accessoryHtml` is injected into the grid via `unsafeStatic`.
491
+ */
492
+ const escapeHtmlAttribute = (value: string): string =>
493
+ value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
494
+
465
495
  export const cellClassesForRowSelection = (selected: boolean, selectionMode: SelectionMode) => {
466
496
  if (!selected) {
467
497
  if (selectionMode === 'single') {