@dxos/react-ui-form 0.8.4-main.7ace549 → 0.8.4-main.937b3ca

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 (167) hide show
  1. package/dist/lib/browser/index.mjs +2191 -2283
  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 +2191 -2283
  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 +7 -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 +6 -5
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +9 -3
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form-old.stories.d.ts +131 -0
  16. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Form/Form.d.ts +81 -27
  18. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts +53 -92
  20. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField.d.ts +37 -0
  22. package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts +78 -0
  24. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
  25. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
  26. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormFieldSet.d.ts +19 -0
  28. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
  30. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
  32. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
  34. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +4 -0
  36. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
  38. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/fields/RefField.d.ts +14 -0
  40. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/fields/SelectField.d.ts +10 -0
  42. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
  44. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
  45. package/dist/types/src/components/Form/{custom/SelectOptionsInput.stories.d.ts → fields/SelectOptionField.stories.d.ts} +6 -3
  46. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
  48. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
  49. package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
  50. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
  51. package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
  52. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
  53. package/dist/types/src/components/Form/fields/index.d.ts +12 -0
  54. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
  55. package/dist/types/src/components/Form/index.d.ts +2 -4
  56. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  57. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +14 -9
  58. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  59. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +5 -2
  60. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  61. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +6 -8
  62. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  63. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +5 -2
  64. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  65. package/dist/types/src/components/testing.d.ts +0 -2
  66. package/dist/types/src/components/testing.d.ts.map +1 -1
  67. package/dist/types/src/hooks/index.d.ts +2 -3
  68. package/dist/types/src/hooks/index.d.ts.map +1 -1
  69. package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
  70. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useKeyHandler.d.ts +6 -0
  72. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
  73. package/dist/types/src/translations.d.ts +6 -3
  74. package/dist/types/src/translations.d.ts.map +1 -1
  75. package/dist/types/src/util/index.d.ts +1 -1
  76. package/dist/types/src/util/index.d.ts.map +1 -1
  77. package/dist/types/src/util/properties.d.ts +7 -0
  78. package/dist/types/src/util/properties.d.ts.map +1 -0
  79. package/dist/types/src/util/refs.d.ts +1 -2
  80. package/dist/types/src/util/refs.d.ts.map +1 -1
  81. package/dist/types/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +49 -44
  83. package/src/components/ControlSection/ControlSection.tsx +8 -8
  84. package/src/components/FieldEditor/FieldEditor.stories.tsx +42 -21
  85. package/src/components/FieldEditor/FieldEditor.tsx +72 -75
  86. package/src/components/Form/Form-old.stories.tsx +314 -0
  87. package/src/components/Form/Form.stories.tsx +146 -246
  88. package/src/components/Form/Form.tsx +319 -110
  89. package/src/components/Form/FormField.tsx +286 -0
  90. package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
  91. package/src/components/Form/FormFieldComponent.tsx +174 -0
  92. package/src/components/Form/FormFieldSet.tsx +125 -0
  93. package/src/components/Form/README.md +3 -0
  94. package/src/components/Form/fields/ArrayField.tsx +151 -0
  95. package/src/components/Form/fields/BooleanField.tsx +22 -0
  96. package/src/components/Form/fields/GeoPointField.tsx +120 -0
  97. package/src/components/Form/fields/MarkdownField.tsx +78 -0
  98. package/src/components/Form/fields/NumberField.tsx +39 -0
  99. package/src/components/Form/fields/RefField.tsx +181 -0
  100. package/src/components/Form/fields/SelectField.tsx +65 -0
  101. package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +17 -14
  102. package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +20 -14
  103. package/src/components/Form/fields/TextAreaField.tsx +38 -0
  104. package/src/components/Form/fields/TextField.tsx +38 -0
  105. package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +15 -8
  106. package/src/components/Form/fields/index.ts +15 -0
  107. package/src/components/Form/index.ts +3 -4
  108. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +15 -8
  109. package/src/components/ObjectPicker/ObjectPicker.tsx +76 -62
  110. package/src/components/ViewEditor/ViewEditor.stories.tsx +29 -25
  111. package/src/components/ViewEditor/ViewEditor.test.tsx +6 -2
  112. package/src/components/ViewEditor/ViewEditor.tsx +220 -154
  113. package/src/components/testing.tsx +9 -7
  114. package/src/hooks/index.ts +2 -3
  115. package/src/hooks/useFormHandler.ts +356 -0
  116. package/src/hooks/useKeyHandler.tsx +37 -0
  117. package/src/translations.ts +8 -6
  118. package/src/util/index.ts +1 -1
  119. package/src/util/properties.ts +18 -0
  120. package/src/util/refs.ts +7 -13
  121. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  122. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  123. package/dist/types/src/components/Form/Defaults.d.ts +0 -14
  124. package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
  125. package/dist/types/src/components/Form/FormActions.d.ts +0 -9
  126. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  127. package/dist/types/src/components/Form/FormContent.d.ts +0 -63
  128. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  129. package/dist/types/src/components/Form/FormContext.d.ts +0 -23
  130. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  131. package/dist/types/src/components/Form/Input.d.ts +0 -29
  132. package/dist/types/src/components/Form/Input.d.ts.map +0 -1
  133. package/dist/types/src/components/Form/RefField.d.ts +0 -22
  134. package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
  135. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
  136. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
  137. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  138. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  139. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  140. package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
  141. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
  142. package/dist/types/src/components/Form/custom/index.d.ts +0 -4
  143. package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
  144. package/dist/types/src/components/Form/factory.d.ts +0 -8
  145. package/dist/types/src/components/Form/factory.d.ts.map +0 -1
  146. package/dist/types/src/hooks/useForm.d.ts +0 -67
  147. package/dist/types/src/hooks/useForm.d.ts.map +0 -1
  148. package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -29
  149. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
  150. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
  151. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
  152. package/dist/types/src/util/schema.d.ts +0 -3
  153. package/dist/types/src/util/schema.d.ts.map +0 -1
  154. package/src/components/Form/ArrayField.tsx +0 -127
  155. package/src/components/Form/Defaults.tsx +0 -205
  156. package/src/components/Form/FormActions.tsx +0 -59
  157. package/src/components/Form/FormContent.tsx +0 -362
  158. package/src/components/Form/FormContext.tsx +0 -110
  159. package/src/components/Form/Input.tsx +0 -54
  160. package/src/components/Form/RefField.tsx +0 -283
  161. package/src/components/Form/custom/GeoPointInput.tsx +0 -80
  162. package/src/components/Form/custom/index.ts +0 -7
  163. package/src/components/Form/factory.tsx +0 -34
  164. package/src/hooks/useForm.ts +0 -296
  165. package/src/hooks/useQueryRefOptions.ts +0 -62
  166. package/src/hooks/useRefQueryLookupHandler.ts +0 -39
  167. package/src/util/schema.ts +0 -12
@@ -7,16 +7,16 @@ import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
9
  import { DXN, Filter, Obj, Query, type QueryAST, Tag, Type } from '@dxos/echo';
10
- import { type EchoSchema, Format } from '@dxos/echo/internal';
10
+ import { type EchoSchema, Format, type Mutable } from '@dxos/echo/internal';
11
11
  import { useQuery } from '@dxos/react-client/echo';
12
- import { useClientProvider, withClientProvider } from '@dxos/react-client/testing';
12
+ import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
13
13
  import { useAsyncEffect } from '@dxos/react-ui';
14
14
  import { withTheme } from '@dxos/react-ui/testing';
15
15
  import { type ProjectionModel, View, getTypenameFromQuery } from '@dxos/schema';
16
16
  import { Employer, Organization, Person, Project } from '@dxos/types';
17
17
 
18
18
  import { translations } from '../../translations';
19
- import { TestLayout, TestPanel, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
19
+ import { TestLayout, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
20
20
 
21
21
  import { ViewEditor, type ViewEditorProps } from './ViewEditor';
22
22
 
@@ -24,7 +24,7 @@ const types = [
24
24
  // TODO(burdon): Get label from annotation.
25
25
  { value: Organization.Organization.typename, label: 'Organization' },
26
26
  { value: Person.Person.typename, label: 'Person' },
27
- { value: Type.getTypename(Project.Project), label: 'Project' },
27
+ { value: Project.Project.typename, label: 'Project' },
28
28
  { value: Employer.Employer.typename, label: 'Employer' },
29
29
  ];
30
30
 
@@ -38,12 +38,12 @@ export type ViewEditorDebugObjects = {
38
38
  type StoryProps = Pick<ViewEditorProps, 'readonly' | 'mode'>;
39
39
 
40
40
  const DefaultStory = (props: StoryProps) => {
41
- const { space } = useClientProvider();
41
+ const { space } = useClientStory();
42
42
  const [schema, setSchema] = useState<EchoSchema>();
43
43
  const [view, setView] = useState<View.View>();
44
44
  const projectionRef = useRef<ProjectionModel>(null);
45
45
 
46
- const tags = useQuery(space, Filter.type(Tag.Tag));
46
+ const tags = useQuery(space?.db, Filter.type(Tag.Tag));
47
47
 
48
48
  useAsyncEffect(async () => {
49
49
  if (space) {
@@ -52,7 +52,7 @@ const DefaultStory = (props: StoryProps) => {
52
52
  email: Format.Email,
53
53
  salary: Format.Currency(),
54
54
  }).pipe(
55
- Type.Obj({
55
+ Type.object({
56
56
  typename: 'example.com/type/Test',
57
57
  version: '0.1.0',
58
58
  }),
@@ -63,7 +63,7 @@ const DefaultStory = (props: StoryProps) => {
63
63
  description: Schema.String,
64
64
  completed: Schema.Boolean,
65
65
  }).pipe(
66
- Type.Obj({
66
+ Type.object({
67
67
  typename: 'example.com/type/Alternate',
68
68
  version: '0.1.0',
69
69
  }),
@@ -90,7 +90,9 @@ const DefaultStory = (props: StoryProps) => {
90
90
  if (props.mode === 'tag') {
91
91
  const queue = target && DXN.tryParse(target) ? target : undefined;
92
92
  const query = queue ? Query.fromAst(newQuery).options({ queues: [queue] }) : Query.fromAst(newQuery);
93
- view.query.ast = query.ast;
93
+ Obj.change(view, (v) => {
94
+ v.query.ast = query.ast as Mutable<typeof query.ast>;
95
+ });
94
96
 
95
97
  const typename = getTypenameFromQuery(query.ast);
96
98
  const [newSchema] = await space.db.schemaRegistry.query({ typename }).run();
@@ -102,10 +104,14 @@ const DefaultStory = (props: StoryProps) => {
102
104
  query,
103
105
  jsonSchema: newSchema.jsonSchema,
104
106
  });
105
- view.projection = Obj.getSnapshot(newView).projection;
107
+ Obj.change(view, (v) => {
108
+ v.projection = Obj.getSnapshot(newView).projection as Mutable<typeof v.projection>;
109
+ });
106
110
  setSchema(() => newSchema);
107
111
  } else {
108
- view.query.ast = newQuery;
112
+ Obj.change(view, (v) => {
113
+ v.query.ast = newQuery as Mutable<typeof newQuery>;
114
+ });
109
115
  schema.updateTypename(getTypenameFromQuery(newQuery));
110
116
  }
111
117
  },
@@ -140,20 +146,18 @@ const DefaultStory = (props: StoryProps) => {
140
146
 
141
147
  return (
142
148
  <TestLayout json={json}>
143
- <TestPanel>
144
- <ViewEditor
145
- ref={projectionRef}
146
- schema={schema}
147
- view={view}
148
- registry={space?.db.schemaRegistry}
149
- mode={props.mode}
150
- readonly={props.readonly}
151
- types={types}
152
- tags={tags}
153
- onQueryChanged={updateViewQuery}
154
- onDelete={handleDelete}
155
- />
156
- </TestPanel>
149
+ <ViewEditor
150
+ ref={projectionRef}
151
+ schema={schema}
152
+ view={view}
153
+ registry={space?.db.schemaRegistry}
154
+ mode={props.mode}
155
+ readonly={props.readonly}
156
+ types={types}
157
+ tags={tags}
158
+ onQueryChanged={updateViewQuery}
159
+ onDelete={handleDelete}
160
+ />
157
161
  </TestLayout>
158
162
  );
159
163
  };
@@ -54,7 +54,7 @@ describe('ViewEditor', () => {
54
54
  const nameField = screen.getByText('name');
55
55
  fireEvent.click(nameField);
56
56
 
57
- const fieldInput = screen.getByPlaceholderText('Field name.');
57
+ const fieldInput = screen.getByPlaceholderText('Property name');
58
58
  fireEvent.change(fieldInput, { target: { value: 'new_property' } });
59
59
 
60
60
  fireEvent.click(screen.getByTestId('save-button'));
@@ -93,8 +93,12 @@ describe('ViewEditor', () => {
93
93
  const addButton = screen.getByText('Add property');
94
94
  fireEvent.click(addButton);
95
95
 
96
+ // Open the last field.
97
+ const buttons = screen.getAllByTestId('field.toggle');
98
+ fireEvent.click(buttons.at(-1)!);
99
+
96
100
  // Fill out the property field.
97
- const fieldInput = screen.getByPlaceholderText('Field name.');
101
+ const fieldInput = screen.getByPlaceholderText('Property name');
98
102
  fireEvent.change(fieldInput, { target: { value: 'added_property' } });
99
103
 
100
104
  // Click the format combo box and select the first option.
@@ -2,33 +2,40 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { RegistryContext } from '@effect-atom/atom-react';
5
6
  import * as Array from 'effect/Array';
6
7
  import * as Match from 'effect/Match';
7
8
  import * as Option from 'effect/Option';
8
9
  import * as Schema from 'effect/Schema';
9
- import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react';
10
+ import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from 'react';
10
11
 
11
- import { QueryAST } from '@dxos/echo';
12
- import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
13
- import { Filter, Query, type SchemaRegistry } from '@dxos/echo-db';
12
+ import { Filter, Format, Obj, Query, QueryAST, type SchemaRegistry } from '@dxos/echo';
13
+ import { EchoSchema, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
14
14
  import { invariant } from '@dxos/invariant';
15
15
  import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
16
  import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
17
17
  import { List } from '@dxos/react-ui-list';
18
- import { cardSpacing } from '@dxos/react-ui-stack';
19
- import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
20
18
  import {
21
19
  FieldSchema,
22
20
  type FieldType,
23
21
  ProjectionModel,
24
22
  VIEW_FIELD_LIMIT,
25
23
  type View,
24
+ createDirectChangeCallback,
25
+ createEchoChangeCallback,
26
26
  getTypenameFromQuery,
27
27
  } from '@dxos/schema';
28
+ import { mx, osTranslations, subtleHover } from '@dxos/ui-theme';
28
29
 
29
30
  import { translationKey } from '../../translations';
30
31
  import { FieldEditor } from '../FieldEditor';
31
- import { Form, type FormProps, type InputComponent, InputHeader, type InputProps } from '../Form';
32
+ import {
33
+ Form,
34
+ type FormFieldComponent,
35
+ type FormFieldComponentProps,
36
+ FormFieldLabel,
37
+ type FormFieldMap,
38
+ } from '../Form';
32
39
 
33
40
  const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
34
41
  const listItemGrid = 'grid grid-cols-subgrid col-span-5';
@@ -38,13 +45,12 @@ export type ViewEditorProps = ThemedClassName<
38
45
  schema: Schema.Schema.AnyNoContext;
39
46
  view: View.View;
40
47
  mode?: 'schema' | 'tag';
41
- registry?: SchemaRegistry;
48
+ registry?: SchemaRegistry.SchemaRegistry;
42
49
  readonly?: boolean;
43
50
  showHeading?: boolean;
44
51
  onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
45
52
  onDelete?: (fieldId: string) => void;
46
- } & Pick<FormProps<any>, 'outerSpacing'> &
47
- Pick<QueryFormProps, 'types' | 'tags'>
53
+ } & Pick<QueryFormProps, 'types' | 'tags'>
48
54
  >;
49
55
 
50
56
  /**
@@ -64,17 +70,33 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
64
70
  tags,
65
71
  onQueryChanged,
66
72
  onDelete,
67
- outerSpacing = true,
68
73
  },
69
74
  forwardedRef,
70
75
  ) => {
76
+ const atomRegistry = useContext(RegistryContext);
71
77
  const schemaReadonly = !isMutable(schema);
72
78
  const { t } = useTranslation(translationKey);
79
+
73
80
  const projectionModel = useMemo(() => {
74
81
  // Use reactive and mutable version of json schema when schema is mutable.
75
82
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
76
- return new ProjectionModel(jsonSchema, view.projection);
77
- }, [schema, JSON.stringify(view.projection)]);
83
+
84
+ // Use createEchoChangeCallback for mutable schemas (EchoSchema), otherwise use direct mutation.
85
+ const change =
86
+ schema instanceof EchoSchema
87
+ ? createEchoChangeCallback(view, schema)
88
+ : createDirectChangeCallback(view.projection, jsonSchema);
89
+
90
+ const model = new ProjectionModel({
91
+ registry: atomRegistry,
92
+ view,
93
+ baseSchema: jsonSchema,
94
+ change,
95
+ });
96
+
97
+ return model;
98
+ }, [atomRegistry, schema, view]);
99
+
78
100
  useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
79
101
  const [expandedField, setExpandedField] = useState<FieldType['id']>();
80
102
 
@@ -116,20 +138,17 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
116
138
  [mode, view.query.ast, queueTarget],
117
139
  );
118
140
 
119
- const handleToggleField = useCallback(
120
- (field: FieldType) => {
121
- setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
122
- },
123
- [readonly],
141
+ const fieldMap = useMemo<FormFieldMap | undefined>(
142
+ () => (mode === 'tag' ? customFields({ types, tags }) : undefined),
143
+ [mode, types, tags],
124
144
  );
125
145
 
126
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
127
-
146
+ // TODO(burdon): Check if mutable.
128
147
  const handleAdd = useCallback(() => {
129
148
  invariant(!readonly);
130
149
  const field = projectionModel.createFieldProjection();
131
150
  setExpandedField(field.id);
132
- }, [schema, projectionModel, readonly]);
151
+ }, [projectionModel, readonly]);
133
152
 
134
153
  const handleUpdate = useCallback(
135
154
  (values: any) => {
@@ -153,143 +172,32 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
153
172
  [expandedField, onDelete, readonly],
154
173
  );
155
174
 
156
- const handleMove = useCallback(
157
- (fromIndex: number, toIndex: number) => {
158
- invariant(!readonly);
159
- // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
160
- const fields = [...view.projection.fields];
161
- const [moved] = fields.splice(fromIndex, 1);
162
- fields.splice(toIndex, 0, moved);
163
- view.projection.fields = fields;
164
- },
165
- [view.projection.fields, readonly],
166
- );
167
-
168
- const handleClose = useCallback(() => setExpandedField(undefined), []);
169
-
170
- const handleHide = useCallback(
171
- (fieldId: string) => {
172
- setExpandedField(undefined);
173
- projectionModel.hideFieldProjection(fieldId);
174
- },
175
- [projectionModel],
176
- );
177
-
178
- const handleShow = useCallback(
179
- (property: string) => {
180
- setExpandedField(undefined);
181
- projectionModel.showFieldProjection(property as JsonProp);
182
- },
183
- [projectionModel],
184
- );
185
-
186
- const custom = useMemo(() => (mode === 'tag' ? customFields({ types, tags }) : undefined), [types, tags, mode]);
187
-
188
175
  return (
189
176
  <div role='none' className={mx(classNames)}>
190
- {/* If readonlyProp is set, then the callout is not needed. */}
177
+ {/* If readonly is set, then the callout is not needed. */}
191
178
  {schemaReadonly && !readonly && (
192
- <Callout.Root valence='info' classNames={['mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}>
179
+ <Callout.Root valence='info' classNames='mlb-cardSpacingBlock'>
193
180
  <Callout.Title>{t('system schema description')}</Callout.Title>
194
181
  </Callout.Root>
195
182
  )}
196
183
 
197
184
  {/* TODO(burdon): Is the form read-only or just the schema? */}
198
- {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
199
- <Form<Schema.Schema.Type<typeof viewSchema>>
200
- autoSave
201
- schema={viewSchema}
202
- values={viewValues}
203
- onSave={handleUpdate}
204
- outerSpacing={outerSpacing}
205
- Custom={custom}
206
- />
207
-
208
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
209
- <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
210
-
211
- <List.Root<FieldType>
212
- items={view.projection.fields}
213
- isItem={Schema.is(FieldSchema)}
214
- getId={(field) => field.id}
215
- onMove={readonly ? undefined : handleMove}
185
+ <Form.Root schema={viewSchema} values={viewValues} fieldMap={fieldMap} autoSave onSave={handleUpdate}>
186
+ <Form.FieldSet />
187
+
188
+ <FormFieldLabel label={t('fields label')} asChild />
189
+ <FieldList
190
+ schema={schema}
191
+ view={view}
192
+ registry={registry}
216
193
  readonly={readonly}
217
- >
218
- {({ items: fields }) => (
219
- <>
220
- {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
221
- <div role='list' className={listGrid}>
222
- {fields?.map((field) => {
223
- const hidden = field.visible === false;
224
- return (
225
- <List.Item<FieldType>
226
- key={field.id}
227
- item={field}
228
- classNames={listItemGrid}
229
- aria-expanded={expandedField === field.id}
230
- >
231
- <div
232
- role='none'
233
- className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}
234
- >
235
- <List.ItemDragHandle disabled={readonly || schemaReadonly} />
236
- <List.ItemTitle
237
- classNames={hidden && 'text-subdued'}
238
- onClick={() => handleToggleField(field)}
239
- >
240
- {field.path}
241
- </List.ItemTitle>
242
- <List.ItemButton
243
- label={t(hidden ? 'show field label' : 'hide field label')}
244
- data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
245
- icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
246
- autoHide={false}
247
- disabled={readonly || (!hidden && projectionModel.fields.length <= 1)}
248
- onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
249
- />
250
- {!readonly && (
251
- <>
252
- <List.ItemDeleteButton
253
- label={t('delete field label')}
254
- autoHide={false}
255
- disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
256
- onClick={() => handleDelete(field.id)}
257
- data-testid='field.delete'
258
- />
259
- <IconButton
260
- iconOnly
261
- variant='ghost'
262
- label={t('toggle expand label', { ns: 'os' })}
263
- icon={
264
- expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'
265
- }
266
- onClick={() => handleToggleField(field)}
267
- />
268
- </>
269
- )}
270
- </div>
271
- {expandedField === field.id && !readonly && (
272
- <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
273
- <FieldEditor
274
- readonly={readonly || schemaReadonly ? 'disabled-input' : false}
275
- projection={projectionModel}
276
- field={field}
277
- registry={registry}
278
- onSave={handleClose}
279
- />
280
- </div>
281
- )}
282
- </List.Item>
283
- );
284
- })}
285
- </div>
286
- </>
287
- )}
288
- </List.Root>
289
- </div>
194
+ showHeading={showHeading}
195
+ onDelete={handleDelete}
196
+ />
197
+ </Form.Root>
290
198
 
291
199
  {!readonly && !expandedField && (
292
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
200
+ <div role='none' className='mlb-cardSpacingBlock'>
293
201
  <IconButton
294
202
  icon='ph--plus--regular'
295
203
  label={t('add property button label')}
@@ -305,17 +213,175 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
305
213
  },
306
214
  );
307
215
 
308
- const customFields = ({ types, tags }: Pick<ViewEditorProps, 'types' | 'tags'>): Record<string, InputComponent> => ({
309
- query: (props: InputProps) => {
310
- const handleChange = useCallback(
311
- (query: Query.Any) => props.onValueChange('object', query.ast),
312
- [props.onValueChange],
216
+ type FieldListProps = Pick<ViewEditorProps, 'schema' | 'view' | 'registry' | 'readonly' | 'showHeading' | 'onDelete'>;
217
+
218
+ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDelete }: FieldListProps) => {
219
+ const atomRegistry = useContext(RegistryContext);
220
+ const schemaReadonly = !isMutable(schema);
221
+ const { t } = useTranslation(translationKey);
222
+
223
+ const projectionModel = useMemo(() => {
224
+ // Use reactive and mutable version of json schema when schema is mutable.
225
+ const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
226
+
227
+ // Use createEchoChangeCallback for mutable schemas (EchoSchema), otherwise use direct mutation.
228
+ const change =
229
+ schema instanceof EchoSchema
230
+ ? createEchoChangeCallback(view, schema)
231
+ : createDirectChangeCallback(view.projection, jsonSchema);
232
+
233
+ const model = new ProjectionModel({
234
+ registry: atomRegistry,
235
+ view,
236
+ baseSchema: jsonSchema,
237
+ change,
238
+ });
239
+
240
+ return model;
241
+ }, [atomRegistry, schema, view]);
242
+
243
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
244
+
245
+ const handleToggleField = useCallback(
246
+ (field: FieldType) => {
247
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
248
+ },
249
+ [readonly],
250
+ );
251
+
252
+ const handleDelete = useCallback(
253
+ (fieldId: string) => {
254
+ invariant(!readonly);
255
+ if (fieldId === expandedField) {
256
+ setExpandedField(undefined);
257
+ }
258
+
259
+ onDelete?.(fieldId);
260
+ },
261
+ [expandedField, onDelete, readonly],
262
+ );
263
+
264
+ const handleMove = useCallback(
265
+ (fromIndex: number, toIndex: number) => {
266
+ invariant(!readonly);
267
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
268
+ const fields = [...view.projection.fields];
269
+ const [moved] = fields.splice(fromIndex, 1);
270
+ fields.splice(toIndex, 0, moved);
271
+ Obj.change(view, (v) => {
272
+ v.projection.fields = fields;
273
+ });
274
+ },
275
+ [view.projection.fields, readonly],
276
+ );
277
+
278
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
279
+
280
+ const handleHide = useCallback(
281
+ (fieldId: string) => {
282
+ setExpandedField(undefined);
283
+ projectionModel.hideFieldProjection(fieldId);
284
+ },
285
+ [projectionModel],
286
+ );
287
+
288
+ const handleShow = useCallback(
289
+ (property: string) => {
290
+ setExpandedField(undefined);
291
+ projectionModel.showFieldProjection(property as JsonProp);
292
+ },
293
+ [projectionModel],
294
+ );
295
+
296
+ return (
297
+ <List.Root<FieldType>
298
+ items={view.projection.fields as FieldType[]}
299
+ isItem={Schema.is(FieldSchema)}
300
+ getId={(field) => field.id}
301
+ onMove={readonly ? undefined : handleMove}
302
+ readonly={readonly}
303
+ >
304
+ {({ items: fields }) => (
305
+ <>
306
+ {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
307
+ <div role='list' className={listGrid}>
308
+ {fields?.map((field) => {
309
+ const hidden = field.visible === false;
310
+ return (
311
+ <List.Item<FieldType>
312
+ key={field.id}
313
+ item={field}
314
+ classNames={listItemGrid}
315
+ aria-expanded={expandedField === field.id}
316
+ >
317
+ <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
318
+ <List.ItemDragHandle disabled={readonly || schemaReadonly} />
319
+ <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
320
+ {field.path}
321
+ </List.ItemTitle>
322
+ <List.ItemButton
323
+ label={t(hidden ? 'show field label' : 'hide field label')}
324
+ data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
325
+ icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
326
+ autoHide={false}
327
+ disabled={readonly || (!hidden && projectionModel.getFields().length <= 1)}
328
+ onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
329
+ />
330
+ {!readonly && (
331
+ <>
332
+ <List.ItemDeleteButton
333
+ label={t('delete field label')}
334
+ autoHide={false}
335
+ disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
336
+ onClick={() => handleDelete(field.id)}
337
+ data-testid='field.delete'
338
+ />
339
+ <IconButton
340
+ iconOnly
341
+ variant='ghost'
342
+ label={t('toggle expand label', { ns: osTranslations })}
343
+ icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
344
+ onClick={() => handleToggleField(field)}
345
+ data-testid='field.toggle'
346
+ />
347
+ </>
348
+ )}
349
+ </div>
350
+ {expandedField === field.id && !readonly && (
351
+ <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
352
+ <FieldEditor
353
+ readonly={readonly || schemaReadonly}
354
+ registry={registry}
355
+ projection={projectionModel}
356
+ field={field}
357
+ onSave={handleClose}
358
+ />
359
+ </div>
360
+ )}
361
+ </List.Item>
362
+ );
363
+ })}
364
+ </div>
365
+ </>
366
+ )}
367
+ </List.Root>
368
+ );
369
+ };
370
+
371
+ const customFields = ({
372
+ types,
373
+ tags,
374
+ }: Pick<ViewEditorProps, 'types' | 'tags'>): Record<string, FormFieldComponent> => ({
375
+ query: ({ type, readonly, label, getValue, onValueChange }: FormFieldComponentProps) => {
376
+ const handleChange = useCallback<NonNullable<QueryFormProps['onChange']>>(
377
+ (query) => onValueChange(type, query.ast),
378
+ [onValueChange],
313
379
  );
314
380
 
315
381
  return (
316
382
  <Input.Root>
317
- <InputHeader label={props.label} />
318
- <QueryForm initialQuery={props.getValue()} types={types} tags={tags} onChange={handleChange} />
383
+ <FormFieldLabel readonly={readonly} label={label} />
384
+ <QueryForm initialQuery={getValue()} types={types} tags={tags} onChange={handleChange} />
319
385
  </Input.Root>
320
386
  );
321
387
  },
@@ -5,25 +5,27 @@
5
5
  import React, { type PropsWithChildren } from 'react';
6
6
 
7
7
  import { type ThemedClassName } from '@dxos/react-ui';
8
- import { Card } from '@dxos/react-ui-stack';
8
+ import { Card } from '@dxos/react-ui-mosaic';
9
9
  import { JsonFilter } from '@dxos/react-ui-syntax-highlighter';
10
- import { mx, textBlockWidth } from '@dxos/react-ui-theme';
10
+ import { textBlockWidth } from '@dxos/ui-theme';
11
11
 
12
12
  type TestLayoutProps = ThemedClassName<PropsWithChildren<{ json?: any }>>;
13
13
 
14
14
  export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
15
15
  return (
16
- <div className='is-full bs-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='is-full text-xs' testId='debug' />
16
+ <div className='is-full bs-full grid grid-cols-[1fr_1fr] p-4 gap-4 overflow-hidden'>
17
+ <TestPanel classNames={['flex flex-col bs-full overflow-y-auto', classNames]}>{children}</TestPanel>
18
+ <TestPanel classNames={'overflow-hidden'}>
19
+ <JsonFilter testId='debug' data={json} classNames='bs-full text-xs' />
20
+ </TestPanel>
19
21
  </div>
20
22
  );
21
23
  };
22
24
 
23
25
  type TestPanelProps = ThemedClassName<PropsWithChildren>;
24
26
 
25
- export const TestPanel = ({ classNames, children }: TestPanelProps) => {
26
- return <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>;
27
+ const TestPanel = ({ classNames, children }: TestPanelProps) => {
28
+ return <Card.Root classNames={['bs-full overflow-y-auto', textBlockWidth, classNames]}>{children}</Card.Root>;
27
29
  };
28
30
 
29
31
  // Symbol for accessing debug objects in tests.
@@ -2,6 +2,5 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './useForm';
6
- export * from './useRefQueryLookupHandler';
7
- export * from './useQueryRefOptions';
5
+ export * from './useFormHandler';
6
+ export * from './useKeyHandler';