@dxos/react-ui-form 0.8.4-main.c1de068 → 0.8.4-main.e098934

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 (94) hide show
  1. package/dist/lib/browser/index.mjs +601 -528
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +601 -528
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/ControlSection/ControlSection.d.ts +2 -2
  8. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
  9. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +79 -4
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +74 -4
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/ArrayField.d.ts +2 -2
  16. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Defaults.d.ts +5 -5
  18. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.d.ts +5 -4
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +126 -30
  22. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormContent.d.ts +16 -10
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts +1 -1
  27. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/Input.d.ts +4 -1
  29. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/RefField.d.ts +10 -4
  31. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -1
  33. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +1 -1
  35. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +74 -3
  37. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -1
  39. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  40. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -2
  41. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +76 -5
  43. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  44. package/dist/types/src/components/index.d.ts +0 -1
  45. package/dist/types/src/components/index.d.ts.map +1 -1
  46. package/dist/types/src/components/testing.d.ts +6 -3
  47. package/dist/types/src/components/testing.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useForm.d.ts +1 -1
  49. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  50. package/dist/types/src/translations.d.ts +9 -4
  51. package/dist/types/src/translations.d.ts.map +1 -1
  52. package/dist/types/src/util/field.d.ts.map +1 -1
  53. package/dist/types/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +35 -30
  55. package/src/components/ControlSection/ControlSection.stories.tsx +8 -10
  56. package/src/components/ControlSection/ControlSection.tsx +26 -12
  57. package/src/components/FieldEditor/FieldEditor.stories.tsx +8 -9
  58. package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
  59. package/src/components/FieldEditor/FieldEditor.tsx +22 -17
  60. package/src/components/Form/ArrayField.tsx +8 -8
  61. package/src/components/Form/Defaults.tsx +22 -20
  62. package/src/components/Form/Form.stories.tsx +129 -93
  63. package/src/components/Form/Form.tsx +16 -12
  64. package/src/components/Form/FormActions.tsx +2 -1
  65. package/src/components/Form/FormContent.tsx +96 -34
  66. package/src/components/Form/FormContext.tsx +5 -5
  67. package/src/components/Form/Input.tsx +4 -1
  68. package/src/components/Form/RefField.tsx +126 -40
  69. package/src/components/Form/custom/GeoPointInput.tsx +29 -21
  70. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +7 -6
  71. package/src/components/Form/custom/SelectOptionsInput.tsx +29 -30
  72. package/src/components/Form/custom/TupleInput.tsx +2 -2
  73. package/src/components/Form/factory.tsx +1 -1
  74. package/src/components/ViewEditor/ViewEditor.stories.tsx +58 -26
  75. package/src/components/ViewEditor/ViewEditor.test.tsx +7 -10
  76. package/src/components/ViewEditor/ViewEditor.tsx +151 -132
  77. package/src/components/index.ts +0 -2
  78. package/src/components/testing.tsx +15 -9
  79. package/src/hooks/useForm.ts +5 -3
  80. package/src/hooks/useRefQueryLookupHandler.ts +1 -1
  81. package/src/translations.ts +11 -5
  82. package/src/util/field.ts +8 -18
  83. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  84. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  85. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  86. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  87. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  88. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  89. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  90. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  91. package/src/components/deprecated/Form.stories.tsx +0 -62
  92. package/src/components/deprecated/Form.tsx +0 -119
  93. package/src/components/deprecated/FormInput.tsx +0 -71
  94. package/src/components/deprecated/index.ts +0 -5
@@ -6,20 +6,22 @@ import '@dxos-theme';
6
6
 
7
7
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import { Schema } from 'effect';
9
- import React, { useCallback, useMemo, useState, useEffect } from 'react';
9
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
10
10
 
11
- import { Obj, Type } from '@dxos/echo';
12
- import { Format, type EchoSchema, toJsonSchema, TypedObject } from '@dxos/echo-schema';
13
- import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
11
+ import { Filter, Obj, Query, Type } from '@dxos/echo';
12
+ import { type EchoSchema, Format, toJsonSchema } from '@dxos/echo-schema';
13
+ import { useSpace } from '@dxos/react-client/echo';
14
14
  import { withClientProvider } from '@dxos/react-client/testing';
15
15
  import { useAsyncEffect } from '@dxos/react-ui';
16
- import { DataType, ProjectionModel, createView } from '@dxos/schema';
17
- import { withTheme, withLayout } from '@dxos/storybook-utils';
16
+ import { QueryParser, createFilter } from '@dxos/react-ui-components';
17
+ import { type DataType, ProjectionModel, createView, typenameFromQuery } from '@dxos/schema';
18
+ import { withLayout, withTheme } from '@dxos/storybook-utils';
18
19
 
19
- import { ViewEditor, type ViewEditorProps } from './ViewEditor';
20
20
  import { translations } from '../../translations';
21
21
  import { TestLayout, TestPanel, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
22
22
 
23
+ import { ViewEditor, type ViewEditorProps } from './ViewEditor';
24
+
23
25
  // Type definition for debug objects exposed to tests.
24
26
  export type ViewEditorDebugObjects = {
25
27
  schema: EchoSchema;
@@ -27,7 +29,7 @@ export type ViewEditorDebugObjects = {
27
29
  projection: ProjectionModel;
28
30
  };
29
31
 
30
- type StoryProps = Pick<ViewEditorProps, 'readonly'>;
32
+ type StoryProps = Pick<ViewEditorProps, 'readonly' | 'kind'>;
31
33
 
32
34
  const DefaultStory = (props: StoryProps) => {
33
35
  const space = useSpace();
@@ -36,16 +38,21 @@ const DefaultStory = (props: StoryProps) => {
36
38
  const [projection, setProjection] = useState<ProjectionModel>();
37
39
  useAsyncEffect(async () => {
38
40
  if (space) {
39
- class TestSchema extends TypedObject({ typename: 'example.com/type/Test', version: '0.1.0' })({
41
+ const TestSchema = Schema.Struct({
40
42
  name: Schema.String,
41
43
  email: Format.Email,
42
44
  salary: Format.Currency(),
43
- }) {}
45
+ }).pipe(
46
+ Type.Obj({
47
+ typename: 'example.com/type/Test',
48
+ version: '0.1.0',
49
+ }),
50
+ );
44
51
 
45
52
  const [schema] = await space.db.schemaRegistry.register([TestSchema]);
46
53
  const view = createView({
47
54
  name: 'Test',
48
- typename: schema.typename,
55
+ query: Query.select(Filter.type(TestSchema)),
49
56
  jsonSchema: toJsonSchema(TestSchema),
50
57
  presentation: Obj.make(Type.Expando, {}),
51
58
  });
@@ -57,20 +64,32 @@ const DefaultStory = (props: StoryProps) => {
57
64
  }
58
65
  }, [space]);
59
66
 
60
- const views = useQuery(space, Filter.type(DataType.View));
61
- const currentTypename = useMemo(() => view?.query?.typename, [view]);
62
- const updateViewTypename = useCallback(
63
- (newTypename: string) => {
64
- if (!schema) {
67
+ const updateViewQuery = useCallback(
68
+ (newQueryString: string) => {
69
+ if (!schema || !view) {
65
70
  return;
66
71
  }
67
- const matchingViews = views.filter((view) => view.query.typename === currentTypename);
68
- for (const view of matchingViews) {
69
- view.query.typename = newTypename;
72
+
73
+ if (props.kind === 'advanced') {
74
+ try {
75
+ const parser = new QueryParser(newQueryString);
76
+ // TODO(wittjosiah): When this fails it should show validation errors in the UI.
77
+ const newQuery = Query.select(createFilter(parser.parse()));
78
+ view.query = newQuery.ast;
79
+
80
+ const typename = typenameFromQuery(newQuery.ast);
81
+ if (typename) {
82
+ schema.updateTypename(typename);
83
+ }
84
+ } catch {}
85
+ } else {
86
+ const typename = newQueryString;
87
+ const newQuery = Query.select(Filter.typename(typename));
88
+ view.query = newQuery.ast;
89
+ schema.updateTypename(typename);
70
90
  }
71
- schema.updateTypename(newTypename);
72
91
  },
73
- [views, schema],
92
+ [view, schema],
74
93
  );
75
94
 
76
95
  const handleDelete = useCallback((property: string) => projection?.deleteFieldProjection(property), [projection]);
@@ -99,8 +118,9 @@ const DefaultStory = (props: StoryProps) => {
99
118
  schema={schema}
100
119
  view={view}
101
120
  registry={space?.db.schemaRegistry}
121
+ kind={props.kind}
102
122
  readonly={props.readonly}
103
- onTypenameChanged={updateViewTypename}
123
+ onQueryChanged={updateViewQuery}
104
124
  onDelete={handleDelete}
105
125
  />
106
126
  </TestPanel>
@@ -108,19 +128,31 @@ const DefaultStory = (props: StoryProps) => {
108
128
  );
109
129
  };
110
130
 
111
- const meta: Meta<StoryProps> = {
131
+ const meta = {
112
132
  title: 'ui/react-ui-form/ViewEditor',
113
133
  render: DefaultStory,
114
- decorators: [withClientProvider({ createSpace: true }), withLayout({ fullscreen: true }), withTheme],
134
+ decorators: [
135
+ withClientProvider({
136
+ createSpace: true,
137
+ }),
138
+ withLayout({
139
+ fullscreen: true,
140
+ }),
141
+ withTheme,
142
+ ],
115
143
  parameters: { translations },
116
- };
144
+ } satisfies Meta<typeof DefaultStory>;
117
145
 
118
146
  export default meta;
119
147
 
120
- type Story = StoryObj<StoryProps>;
148
+ type Story = StoryObj<typeof meta>;
121
149
 
122
150
  export const Default: Story = {};
123
151
 
124
152
  export const Readonly: Story = {
125
153
  args: { readonly: true },
126
154
  };
155
+
156
+ export const Advanced: Story = {
157
+ args: { kind: 'advanced' },
158
+ };
@@ -3,15 +3,16 @@
3
3
  //
4
4
 
5
5
  import { composeStories } from '@storybook/react';
6
- import { screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
6
+ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
7
7
  import { afterEach, describe, expect, test } from 'vitest';
8
8
 
9
9
  import { EchoSchema, isInstanceOf } from '@dxos/echo-schema';
10
10
  import { DataType, ProjectionModel } from '@dxos/schema';
11
11
 
12
+ import { VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
13
+
12
14
  import * as stories from './ViewEditor.stories';
13
15
  import { type ViewEditorDebugObjects } from './ViewEditor.stories';
14
- import { VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
15
16
 
16
17
  const { Default } = composeStories(stories);
17
18
 
@@ -41,7 +42,7 @@ describe('ViewEditor', () => {
41
42
  test('renders view editor', async () => {
42
43
  await Default.run();
43
44
  await waitForViewEditor();
44
- expect(screen.getByText('Typename')).toBeInTheDocument();
45
+ expect(screen.getByText('Record type')).toBeInTheDocument();
45
46
  });
46
47
 
47
48
  test('change property name', async () => {
@@ -140,7 +141,7 @@ describe('ViewEditor', () => {
140
141
  // Find the delete button for the 'name' property.
141
142
  const nameProperty = screen.getByText('name');
142
143
  const propertyRow = nameProperty.closest('[role="listitem"]');
143
- const deleteButton = propertyRow?.querySelector('button:last-child');
144
+ const deleteButton = propertyRow?.querySelector('[data-testid="field.delete"]');
144
145
 
145
146
  if (!deleteButton) {
146
147
  throw new Error('Delete button not found');
@@ -188,19 +189,15 @@ describe('ViewEditor', () => {
188
189
  // Access the live objects directly from window using symbol.
189
190
  const debugObjects = getViewEditorDebugObjects();
190
191
 
191
- // Check that hiddenFields is non-empty.
192
- expect(debugObjects.view.projection.hiddenFields).toBeDefined();
193
- expect(debugObjects.view.projection.hiddenFields!.length).toBeGreaterThan(0);
194
-
195
192
  // Click the show button
196
193
  fireEvent.click(screen.getByTestId('show-field-button'));
197
194
 
198
195
  // Wait for the data to update and check that hiddenFields is empty.
199
196
  await waitFor(() => {
200
- expect(debugObjects.view.projection.hiddenFields!.length).toBe(0);
197
+ expect(debugObjects.view.projection.fields.filter(({ visible }) => visible === false)!.length).toBe(0);
201
198
  });
202
199
 
203
200
  // Also verify that the field is back in the visible fields list.
204
- expect(debugObjects.view.projection.fields.length).toBeGreaterThan(0);
201
+ expect(debugObjects.view.projection.fields.filter(({ visible }) => visible !== false).length).toBeGreaterThan(0);
205
202
  });
206
203
  });
@@ -2,40 +2,43 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Schema } from 'effect';
5
+ import { Match, Schema } from 'effect';
6
6
  import React, { useCallback, useMemo, useState } from 'react';
7
7
 
8
8
  import { type SchemaRegistry } from '@dxos/echo-db';
9
9
  import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo-schema';
10
10
  import { invariant } from '@dxos/invariant';
11
+ import { getSpace } from '@dxos/react-client/echo';
11
12
  import { Callout, IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
13
+ import { QueryEditor, QuerySerializer, createExpression } from '@dxos/react-ui-components';
12
14
  import { List } from '@dxos/react-ui-list';
13
15
  import { cardSpacing } from '@dxos/react-ui-stack';
14
- import { ghostHover, inputTextLabel, mx } from '@dxos/react-ui-theme';
15
- import { FieldSchema, type FieldType, VIEW_FIELD_LIMIT, type DataType, ProjectionModel } from '@dxos/schema';
16
+ import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
17
+ import {
18
+ type DataType,
19
+ FieldSchema,
20
+ type FieldType,
21
+ ProjectionModel,
22
+ VIEW_FIELD_LIMIT,
23
+ typenameFromQuery,
24
+ } from '@dxos/schema';
16
25
 
17
26
  import { translationKey } from '../../translations';
18
27
  import { FieldEditor } from '../FieldEditor';
19
- import { Form, type FormProps } from '../Form';
28
+ import { Form, type FormProps, type InputComponent, type InputProps } from '../Form';
20
29
 
21
- const grid = 'grid grid-cols-[32px_1fr_32px_32px] min-bs-[2.5rem]';
22
-
23
- const ViewMetaSchema = Schema.Struct({
24
- typename: Format.URL.annotations({
25
- title: 'Typename',
26
- }),
27
- }).pipe(Schema.mutable);
28
-
29
- type ViewMetaType = Schema.Schema.Type<typeof ViewMetaSchema>;
30
+ const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
31
+ const listItemGrid = 'grid grid-cols-subgrid col-span-5';
30
32
 
31
33
  export type ViewEditorProps = ThemedClassName<
32
34
  {
33
35
  schema: Schema.Schema.AnyNoContext;
34
36
  view: DataType.View;
37
+ kind?: 'basic' | 'advanced';
35
38
  registry?: SchemaRegistry;
36
39
  readonly?: boolean;
37
40
  showHeading?: boolean;
38
- onTypenameChanged?: (typename: string) => void;
41
+ onQueryChanged?: (query: string) => void;
39
42
  onDelete?: (fieldId: string) => void;
40
43
  } & Pick<FormProps<any>, 'outerSpacing'>
41
44
  >;
@@ -47,37 +50,52 @@ export const ViewEditor = ({
47
50
  classNames,
48
51
  schema,
49
52
  view,
53
+ kind = 'basic',
50
54
  registry,
51
- readonly: _readonly,
55
+ readonly,
52
56
  showHeading = false,
53
- onTypenameChanged,
57
+ onQueryChanged,
54
58
  onDelete,
55
59
  outerSpacing = true,
56
60
  }: ViewEditorProps) => {
61
+ const space = getSpace(view);
62
+ const schemaReadonly = !isMutable(schema);
57
63
  const { t } = useTranslation(translationKey);
58
- const projection = useMemo(() => {
64
+ const projectionModel = useMemo(() => {
59
65
  // Use reactive and mutable version of json schema when schema is mutable.
60
66
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
61
67
  return new ProjectionModel(jsonSchema, view.projection);
62
68
  }, [schema, view.projection]);
63
- const [field, setField] = useState<FieldType>();
64
- const readonly = _readonly || !isMutable(schema);
69
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
70
+
71
+ const serializedQuery = Match.value(kind).pipe(
72
+ Match.when('basic', () => typenameFromQuery(view.query)),
73
+ Match.when('advanced', () => {
74
+ if (view.query.type !== 'select') {
75
+ return '';
76
+ }
77
+
78
+ const serializer = new QuerySerializer();
79
+ return serializer.serialize(createExpression(view.query.filter));
80
+ }),
81
+ Match.exhaustive,
82
+ );
65
83
 
66
- // TODO(burdon): Should be reactive.
67
- const viewValues = useMemo(() => {
68
- return {
69
- // TODO(burdon): Need to warn user of possible consequences of editing.
70
- // TODO(burdon): Settings should have domain name owned by user.
71
- typename: view.query.typename,
72
- };
73
- }, [view.query.typename]);
84
+ const viewSchema = useMemo(() => {
85
+ return Schema.Struct({
86
+ query:
87
+ kind === 'basic'
88
+ ? Format.URL.annotations({ title: 'Record type' })
89
+ : Schema.String.annotations({ title: 'Query' }),
90
+ }).pipe(Schema.mutable);
91
+ }, [kind]);
92
+ // TODO(burdon): Need to warn user of possible consequences of editing.
93
+ // TODO(burdon): Settings should have domain name owned by user.
94
+ const viewValues = useMemo(() => ({ query: serializedQuery }), [serializedQuery]);
74
95
 
75
- const handleSelect = useCallback(
96
+ const handleToggleField = useCallback(
76
97
  (field: FieldType) => {
77
- if (readonly) {
78
- return;
79
- }
80
- setField((f) => (f === field ? undefined : field));
98
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
81
99
  },
82
100
  [readonly],
83
101
  );
@@ -86,32 +104,32 @@ export const ViewEditor = ({
86
104
 
87
105
  const handleAdd = useCallback(() => {
88
106
  invariant(!readonly);
89
- const field = projection.createFieldProjection();
90
- setField(field);
91
- }, [schema, projection, readonly]);
107
+ const field = projectionModel.createFieldProjection();
108
+ setExpandedField(field.id);
109
+ }, [schema, projectionModel, readonly]);
92
110
 
93
111
  const handleUpdate = useCallback(
94
- ({ typename }: ViewMetaType) => {
112
+ ({ query }: Schema.Schema.Type<typeof viewSchema>) => {
95
113
  invariant(!readonly);
96
114
  requestAnimationFrame(() => {
97
- if (view.query.typename !== typename && !readonly) {
98
- onTypenameChanged?.(typename);
115
+ if (serializedQuery !== query && !readonly) {
116
+ onQueryChanged?.(query);
99
117
  }
100
118
  });
101
119
  },
102
- [view.query.typename, onTypenameChanged, readonly],
120
+ [serializedQuery, onQueryChanged, readonly],
103
121
  );
104
122
 
105
123
  const handleDelete = useCallback(
106
124
  (fieldId: string) => {
107
125
  invariant(!readonly);
108
- if (fieldId === field?.id) {
109
- setField(undefined);
126
+ if (fieldId === expandedField) {
127
+ setExpandedField(undefined);
110
128
  }
111
129
 
112
130
  onDelete?.(fieldId);
113
131
  },
114
- [field, onDelete, readonly],
132
+ [expandedField, onDelete, readonly],
115
133
  );
116
134
 
117
135
  const handleMove = useCallback(
@@ -126,134 +144,135 @@ export const ViewEditor = ({
126
144
  [view.projection.fields, readonly],
127
145
  );
128
146
 
129
- const handleClose = useCallback(() => setField(undefined), []);
130
-
131
- const hiddenProperties = projection.getHiddenProperties();
147
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
132
148
 
133
149
  const handleHide = useCallback(
134
150
  (fieldId: string) => {
135
- setField(undefined);
136
- projection.hideFieldProjection(fieldId);
151
+ setExpandedField(undefined);
152
+ projectionModel.hideFieldProjection(fieldId);
137
153
  },
138
- [projection],
154
+ [projectionModel],
139
155
  );
140
156
 
141
157
  const handleShow = useCallback(
142
158
  (property: string) => {
143
- setField(undefined);
144
- projection.showFieldProjection(property as JsonProp);
159
+ setExpandedField(undefined);
160
+ projectionModel.showFieldProjection(property as JsonProp);
145
161
  },
146
- [projection],
162
+ [projectionModel],
163
+ );
164
+
165
+ const custom: Partial<Record<string, InputComponent>> = useMemo(
166
+ () =>
167
+ kind === 'advanced'
168
+ ? {
169
+ ['query' satisfies keyof Schema.Schema.Type<typeof viewSchema>]: (props: InputProps) => {
170
+ const handleChange = useCallback(
171
+ (text: string) => props.onValueChange('string', text),
172
+ [props.onValueChange],
173
+ );
174
+
175
+ // TODO(wittjosiah): Add label & input styles.
176
+ return <QueryEditor space={space} initialValue={props.getValue()} onChange={handleChange} />;
177
+ },
178
+ }
179
+ : {},
180
+ [],
147
181
  );
148
182
 
149
183
  return (
150
184
  <div role='none' className={mx(classNames)}>
151
- {readonly && (
152
- <div role='none' className={mx('is-full', outerSpacing && 'plb-cardSpacingBlock pli-cardSpacingInline')}>
153
- <Callout.Root valence='info'>
154
- <Callout.Title>{t('system schema description')}</Callout.Title>
155
- </Callout.Root>
156
- </div>
185
+ {schemaReadonly && (
186
+ <Callout.Root valence='info' classNames={['mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}>
187
+ <Callout.Title>{t('system schema description')}</Callout.Title>
188
+ </Callout.Root>
157
189
  )}
158
190
 
159
191
  {/* TODO(burdon): Is the form read-only or just the schema? */}
160
192
  {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
161
- <Form<ViewMetaType>
193
+ <Form<Schema.Schema.Type<typeof viewSchema>>
162
194
  autoSave
163
- schema={ViewMetaSchema}
195
+ schema={viewSchema}
164
196
  values={viewValues}
165
- readonly={readonly}
197
+ readonly={readonly ? 'disabled-input' : false}
166
198
  onSave={handleUpdate}
167
199
  outerSpacing={outerSpacing}
200
+ Custom={custom}
168
201
  />
169
202
 
170
203
  <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
171
- <label className={mx(inputTextLabel)}>{t('fields label')}</label>
204
+ <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
172
205
 
173
206
  <List.Root<FieldType>
174
207
  items={view.projection.fields}
175
208
  isItem={Schema.is(FieldSchema)}
176
209
  getId={(field) => field.id}
177
- readonly={readonly}
178
210
  onMove={readonly ? undefined : handleMove}
211
+ readonly={readonly}
179
212
  >
180
213
  {({ items: fields }) => (
181
214
  <>
182
- {showHeading && (
183
- <div role='heading' className={grid}>
184
- <div />
185
- <div className='flex items-center text-sm'>{t('field path label')}</div>
186
- </div>
187
- )}
188
-
189
- <div role='list' className='flex flex-col is-full'>
190
- {fields?.map((field) => (
191
- <List.Item<FieldType>
192
- key={field.id}
193
- item={field}
194
- classNames={mx(grid, ghostHover, 'overflow-hidden', !readonly && 'cursor-pointer')}
195
- >
196
- <List.ItemDragHandle />
197
- <List.ItemTitle onClick={() => handleSelect(field)}>{field.path}</List.ItemTitle>
198
- <List.ItemButton
199
- data-testid='hide-field-button'
200
- icon='ph--eye-slash--regular'
201
- // TDOO(burdon): Is this the correct test?
202
- disabled={view.projection.fields.length <= 1}
203
- onClick={() => handleHide(field.id)}
204
- />
205
- {/* TODO(burdon): Remove unless implement undo. */}
206
- {!readonly && (
207
- <List.ItemDeleteButton
208
- disabled={view.projection.fields.length <= 1}
209
- onClick={() => handleDelete(field.id)}
210
- />
211
- )}
212
- </List.Item>
213
- ))}
215
+ {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
216
+ <div role='list' className={listGrid}>
217
+ {fields?.map((field) => {
218
+ const hidden = field.visible === false;
219
+ return (
220
+ <List.Item<FieldType>
221
+ key={field.id}
222
+ item={field}
223
+ classNames={listItemGrid}
224
+ aria-expanded={expandedField === field.id}
225
+ >
226
+ <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
227
+ <List.ItemDragHandle disabled={readonly} />
228
+ <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
229
+ {field.path}
230
+ </List.ItemTitle>
231
+ <List.ItemButton
232
+ label={t(hidden ? 'show field label' : 'hide field label')}
233
+ data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
234
+ icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
235
+ autoHide={false}
236
+ disabled={readonly || (!hidden && projectionModel.fields.length <= 1)}
237
+ onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
238
+ />
239
+ {/* TODO(burdon): Remove unless implement undo. */}
240
+ <List.ItemDeleteButton
241
+ label={t('delete field label')}
242
+ autoHide={false}
243
+ disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
244
+ onClick={() => handleDelete(field.id)}
245
+ data-testid='field.delete'
246
+ />
247
+ <IconButton
248
+ iconOnly
249
+ variant='ghost'
250
+ label={t('toggle expand label', { ns: 'os' })}
251
+ icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
252
+ onClick={() => handleToggleField(field)}
253
+ />
254
+ </div>
255
+ {expandedField === field.id && (
256
+ <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
257
+ <FieldEditor
258
+ readonly={readonly || schemaReadonly ? 'disabled-input' : false}
259
+ projection={projectionModel}
260
+ field={field}
261
+ registry={registry}
262
+ onSave={handleClose}
263
+ />
264
+ </div>
265
+ )}
266
+ </List.Item>
267
+ );
268
+ })}
214
269
  </div>
215
270
  </>
216
271
  )}
217
272
  </List.Root>
218
-
219
- {hiddenProperties.length > 0 && (
220
- <>
221
- <label className={mx(inputTextLabel)}>{t('hidden fields label')}</label>
222
-
223
- <List.Root<string>
224
- items={hiddenProperties}
225
- isItem={(item): item is string => typeof item === 'string'}
226
- getId={(property) => property}
227
- >
228
- {({ items: properties }) => (
229
- <div role='list' className='flex flex-col is-full'>
230
- {properties?.map((property) => (
231
- <List.Item<string>
232
- key={property}
233
- item={property}
234
- classNames={mx(grid, ghostHover, !readonly && 'cursor-pointer')}
235
- >
236
- <List.ItemDragHandle disabled />
237
- <List.ItemTitle>{property}</List.ItemTitle>
238
- <List.ItemButton
239
- icon='ph--eye--regular'
240
- onClick={() => handleShow(property)}
241
- data-testid='show-field-button'
242
- />
243
- </List.Item>
244
- ))}
245
- </div>
246
- )}
247
- </List.Root>
248
- </>
249
- )}
250
273
  </div>
251
274
 
252
- {field && (
253
- <FieldEditor key={field.id} projection={projection} field={field} registry={registry} onSave={handleClose} />
254
- )}
255
-
256
- {!readonly && !field && (
275
+ {!readonly && !expandedField && (
257
276
  <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
258
277
  <IconButton
259
278
  icon='ph--plus--regular'
@@ -261,7 +280,7 @@ export const ViewEditor = ({
261
280
  onClick={readonly ? undefined : handleAdd}
262
281
  // TODO(burdon): Show field limit in ux (not tooltip).
263
282
  disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
264
- classNames='flex is-full'
283
+ classNames='is-full'
265
284
  />
266
285
  </div>
267
286
  )}
@@ -2,8 +2,6 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './deprecated';
6
-
7
5
  export * from './ControlSection';
8
6
  export * from './FieldEditor';
9
7
  export * from './Form';
@@ -9,16 +9,22 @@ import { Card } from '@dxos/react-ui-stack';
9
9
  import { JsonFilter } from '@dxos/react-ui-syntax-highlighter';
10
10
  import { mx, textBlockWidth } from '@dxos/react-ui-theme';
11
11
 
12
- export const TestLayout = ({ classNames, json, children }: ThemedClassName<PropsWithChildren<{ json?: any }>>) => (
13
- <div className='w-full h-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
14
- <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
15
- <JsonFilter data={json} classNames='w-full text-xs' testId='debug' />
16
- </div>
17
- );
12
+ type TestLayoutProps = ThemedClassName<PropsWithChildren<{ json?: any }>>;
18
13
 
19
- export const TestPanel = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
20
- <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>
21
- );
14
+ export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
15
+ return (
16
+ <div className='w-full h-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
17
+ <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
18
+ <JsonFilter data={json} classNames='w-full text-xs' testId='debug' />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ type TestPanelProps = ThemedClassName<PropsWithChildren>;
24
+
25
+ export const TestPanel = ({ classNames, children }: TestPanelProps) => {
26
+ return <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>;
27
+ };
22
28
 
23
29
  // Symbol for accessing debug objects in tests.
24
30
  export const VIEW_EDITOR_DEBUG_SYMBOL = Symbol('viewEditorDebug');