@dxos/react-ui-form 0.8.4-main.1da679c → 0.8.4-main.21d9917

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 (180) hide show
  1. package/dist/lib/browser/index.mjs +2188 -2119
  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 +2188 -2119
  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 +14 -10
  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 +16 -9
  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 -26
  18. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts +63 -87
  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} +13 -9
  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 +46 -0
  58. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +85 -0
  60. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  61. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  62. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  63. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +21 -10
  64. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  65. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +17 -13
  66. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +1 -0
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/components/testing.d.ts +0 -2
  70. package/dist/types/src/components/testing.d.ts.map +1 -1
  71. package/dist/types/src/hooks/index.d.ts +2 -3
  72. package/dist/types/src/hooks/index.d.ts.map +1 -1
  73. package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
  74. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useKeyHandler.d.ts +6 -0
  76. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
  77. package/dist/types/src/translations.d.ts +12 -8
  78. package/dist/types/src/translations.d.ts.map +1 -1
  79. package/dist/types/src/util/field.d.ts +3 -3
  80. package/dist/types/src/util/field.d.ts.map +1 -1
  81. package/dist/types/src/util/formatting.d.ts +2 -2
  82. package/dist/types/src/util/formatting.d.ts.map +1 -1
  83. package/dist/types/src/util/index.d.ts +1 -1
  84. package/dist/types/src/util/index.d.ts.map +1 -1
  85. package/dist/types/src/util/properties.d.ts +7 -0
  86. package/dist/types/src/util/properties.d.ts.map +1 -0
  87. package/dist/types/src/util/refs.d.ts +2 -3
  88. package/dist/types/src/util/refs.d.ts.map +1 -1
  89. package/dist/types/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +50 -44
  91. package/src/components/ControlSection/ControlSection.stories.tsx +4 -5
  92. package/src/components/ControlSection/ControlSection.tsx +8 -8
  93. package/src/components/FieldEditor/FieldEditor.stories.tsx +46 -24
  94. package/src/components/FieldEditor/FieldEditor.tsx +77 -77
  95. package/src/components/Form/Form-old.stories.tsx +314 -0
  96. package/src/components/Form/Form.stories.tsx +151 -251
  97. package/src/components/Form/Form.tsx +319 -100
  98. package/src/components/Form/FormField.tsx +286 -0
  99. package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
  100. package/src/components/Form/FormFieldComponent.tsx +174 -0
  101. package/src/components/Form/FormFieldSet.tsx +125 -0
  102. package/src/components/Form/README.md +3 -0
  103. package/src/components/Form/fields/ArrayField.tsx +151 -0
  104. package/src/components/Form/fields/BooleanField.tsx +22 -0
  105. package/src/components/Form/fields/GeoPointField.tsx +120 -0
  106. package/src/components/Form/fields/MarkdownField.tsx +78 -0
  107. package/src/components/Form/fields/NumberField.tsx +39 -0
  108. package/src/components/Form/fields/RefField.tsx +181 -0
  109. package/src/components/Form/fields/SelectField.tsx +65 -0
  110. package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +21 -19
  111. package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +22 -16
  112. package/src/components/Form/fields/TextAreaField.tsx +38 -0
  113. package/src/components/Form/fields/TextField.tsx +38 -0
  114. package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +15 -8
  115. package/src/components/Form/fields/index.ts +15 -0
  116. package/src/components/Form/index.ts +3 -4
  117. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +214 -0
  118. package/src/components/ObjectPicker/ObjectPicker.tsx +175 -0
  119. package/src/components/ObjectPicker/index.ts +5 -0
  120. package/src/components/ViewEditor/ViewEditor.stories.tsx +112 -68
  121. package/src/components/ViewEditor/ViewEditor.test.tsx +12 -7
  122. package/src/components/ViewEditor/ViewEditor.tsx +283 -184
  123. package/src/components/index.ts +1 -0
  124. package/src/components/testing.tsx +9 -7
  125. package/src/hooks/index.ts +2 -3
  126. package/src/hooks/useFormHandler.ts +356 -0
  127. package/src/hooks/useKeyHandler.tsx +37 -0
  128. package/src/translations.ts +14 -11
  129. package/src/util/field.ts +25 -25
  130. package/src/util/formatting.ts +22 -22
  131. package/src/util/index.ts +1 -1
  132. package/src/util/properties.ts +18 -0
  133. package/src/util/refs.ts +12 -18
  134. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  135. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  136. package/dist/types/src/components/Form/Defaults.d.ts +0 -14
  137. package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
  138. package/dist/types/src/components/Form/FormActions.d.ts +0 -9
  139. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  140. package/dist/types/src/components/Form/FormContent.d.ts +0 -63
  141. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  142. package/dist/types/src/components/Form/FormContext.d.ts +0 -23
  143. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  144. package/dist/types/src/components/Form/Input.d.ts +0 -29
  145. package/dist/types/src/components/Form/Input.d.ts.map +0 -1
  146. package/dist/types/src/components/Form/RefField.d.ts +0 -18
  147. package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
  148. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
  149. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
  150. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  151. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  152. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  153. package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
  154. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
  155. package/dist/types/src/components/Form/custom/index.d.ts +0 -4
  156. package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
  157. package/dist/types/src/components/Form/factory.d.ts +0 -8
  158. package/dist/types/src/components/Form/factory.d.ts.map +0 -1
  159. package/dist/types/src/hooks/useForm.d.ts +0 -67
  160. package/dist/types/src/hooks/useForm.d.ts.map +0 -1
  161. package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -23
  162. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
  163. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
  164. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
  165. package/dist/types/src/util/schema.d.ts +0 -3
  166. package/dist/types/src/util/schema.d.ts.map +0 -1
  167. package/src/components/Form/ArrayField.tsx +0 -124
  168. package/src/components/Form/Defaults.tsx +0 -205
  169. package/src/components/Form/FormActions.tsx +0 -54
  170. package/src/components/Form/FormContent.tsx +0 -302
  171. package/src/components/Form/FormContext.tsx +0 -110
  172. package/src/components/Form/Input.tsx +0 -54
  173. package/src/components/Form/RefField.tsx +0 -299
  174. package/src/components/Form/custom/GeoPointInput.tsx +0 -80
  175. package/src/components/Form/custom/index.ts +0 -7
  176. package/src/components/Form/factory.tsx +0 -31
  177. package/src/hooks/useForm.ts +0 -293
  178. package/src/hooks/useQueryRefOptions.ts +0 -53
  179. package/src/hooks/useRefQueryLookupHandler.ts +0 -39
  180. package/src/util/schema.ts +0 -14
@@ -2,30 +2,40 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Match, Schema } from 'effect';
6
- import React, { useCallback, useMemo, useState } from 'react';
5
+ import { RegistryContext } from '@effect-atom/atom-react';
6
+ import * as Array from 'effect/Array';
7
+ import * as Match from 'effect/Match';
8
+ import * as Option from 'effect/Option';
9
+ import * as Schema from 'effect/Schema';
10
+ import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from 'react';
7
11
 
8
- import { type SchemaRegistry } from '@dxos/echo-db';
9
- import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo-schema';
12
+ import { Filter, Format, Obj, Query, QueryAST, type SchemaRegistry } from '@dxos/echo';
13
+ import { EchoSchema, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
10
14
  import { invariant } from '@dxos/invariant';
11
- import { getSpace } from '@dxos/react-client/echo';
12
- import { Callout, IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
13
- import { QueryEditor, QuerySerializer, createExpression } from '@dxos/react-ui-components';
15
+ import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
+ import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
14
17
  import { List } from '@dxos/react-ui-list';
15
- import { cardSpacing } from '@dxos/react-ui-stack';
16
- import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
17
18
  import {
18
- type DataType,
19
19
  FieldSchema,
20
20
  type FieldType,
21
21
  ProjectionModel,
22
22
  VIEW_FIELD_LIMIT,
23
- typenameFromQuery,
23
+ type View,
24
+ createDirectChangeCallback,
25
+ createEchoChangeCallback,
26
+ getTypenameFromQuery,
24
27
  } from '@dxos/schema';
28
+ import { mx, osTranslations, subtleHover } from '@dxos/ui-theme';
25
29
 
26
30
  import { translationKey } from '../../translations';
27
31
  import { FieldEditor } from '../FieldEditor';
28
- import { Form, type FormProps, type InputComponent, type InputProps } from '../Form';
32
+ import {
33
+ Form,
34
+ type FormFieldComponent,
35
+ type FormFieldComponentProps,
36
+ FormFieldLabel,
37
+ type FormFieldMap,
38
+ } from '../Form';
29
39
 
30
40
  const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
31
41
  const listItemGrid = 'grid grid-cols-subgrid col-span-5';
@@ -33,65 +43,204 @@ const listItemGrid = 'grid grid-cols-subgrid col-span-5';
33
43
  export type ViewEditorProps = ThemedClassName<
34
44
  {
35
45
  schema: Schema.Schema.AnyNoContext;
36
- view: DataType.View;
37
- kind?: 'basic' | 'advanced';
38
- registry?: SchemaRegistry;
46
+ view: View.View;
47
+ mode?: 'schema' | 'tag';
48
+ registry?: SchemaRegistry.SchemaRegistry;
39
49
  readonly?: boolean;
40
50
  showHeading?: boolean;
41
- onQueryChanged?: (query: string) => void;
51
+ onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
42
52
  onDelete?: (fieldId: string) => void;
43
- } & Pick<FormProps<any>, 'outerSpacing'>
53
+ } & Pick<QueryFormProps, 'types' | 'tags'>
44
54
  >;
45
55
 
46
56
  /**
47
57
  * Schema-based object form.
48
58
  */
49
- export const ViewEditor = ({
50
- classNames,
51
- schema,
52
- view,
53
- kind = 'basic',
54
- registry,
55
- readonly,
56
- showHeading = false,
57
- onQueryChanged,
58
- onDelete,
59
- outerSpacing = true,
60
- }: ViewEditorProps) => {
61
- const space = getSpace(view);
59
+ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
60
+ (
61
+ {
62
+ classNames,
63
+ schema,
64
+ view,
65
+ mode = 'schema',
66
+ registry,
67
+ readonly,
68
+ showHeading = false,
69
+ types,
70
+ tags,
71
+ onQueryChanged,
72
+ onDelete,
73
+ },
74
+ forwardedRef,
75
+ ) => {
76
+ const atomRegistry = useContext(RegistryContext);
77
+ const schemaReadonly = !isMutable(schema);
78
+ const { t } = useTranslation(translationKey);
79
+
80
+ const projectionModel = useMemo(() => {
81
+ // Use reactive and mutable version of json schema when schema is mutable.
82
+ const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
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
+
100
+ useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
101
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
102
+
103
+ const queueTarget = Match.value(view.query.ast).pipe(
104
+ Match.when({ type: 'options' }, ({ options }) => {
105
+ return Option.fromNullable(options.queues).pipe(
106
+ Option.flatMap((queues) => Array.head(queues)),
107
+ Option.getOrUndefined,
108
+ );
109
+ }),
110
+ Match.orElse(() => undefined),
111
+ );
112
+
113
+ const viewSchema = useMemo(() => {
114
+ const base = Schema.Struct({
115
+ query:
116
+ mode === 'schema'
117
+ ? Format.URL.annotations({ title: 'Record type' })
118
+ : QueryAST.Query.annotations({ title: 'Query' }),
119
+ });
120
+
121
+ if (mode === 'tag') {
122
+ return Schema.Struct({
123
+ ...base.fields,
124
+ target: Schema.optional(Schema.String.annotations({ title: 'Target Queue' })),
125
+ }).pipe(Schema.mutable);
126
+ }
127
+
128
+ return base.pipe(Schema.mutable);
129
+ }, [mode]);
130
+
131
+ // TODO(burdon): Need to warn user of possible consequences of editing.
132
+ // TODO(burdon): Settings should have domain name owned by user.
133
+ const viewValues = useMemo(
134
+ () => ({
135
+ query: mode === 'schema' ? getTypenameFromQuery(view.query.ast) : view.query.ast,
136
+ target: queueTarget,
137
+ }),
138
+ [mode, view.query.ast, queueTarget],
139
+ );
140
+
141
+ const fieldMap = useMemo<FormFieldMap | undefined>(
142
+ () => (mode === 'tag' ? customFields({ types, tags }) : undefined),
143
+ [mode, types, tags],
144
+ );
145
+
146
+ // TODO(burdon): Check if mutable.
147
+ const handleAdd = useCallback(() => {
148
+ invariant(!readonly);
149
+ const field = projectionModel.createFieldProjection();
150
+ setExpandedField(field.id);
151
+ }, [projectionModel, readonly]);
152
+
153
+ const handleUpdate = useCallback(
154
+ (values: any) => {
155
+ requestAnimationFrame(() => {
156
+ const query = mode === 'schema' ? Query.select(Filter.typename(values.query)).ast : values.query;
157
+ onQueryChanged?.(query, values.target);
158
+ });
159
+ },
160
+ [onQueryChanged, view, queueTarget, mode],
161
+ );
162
+
163
+ const handleDelete = useCallback(
164
+ (fieldId: string) => {
165
+ invariant(!readonly);
166
+ if (fieldId === expandedField) {
167
+ setExpandedField(undefined);
168
+ }
169
+
170
+ onDelete?.(fieldId);
171
+ },
172
+ [expandedField, onDelete, readonly],
173
+ );
174
+
175
+ return (
176
+ <div role='none' className={mx(classNames)}>
177
+ {/* If readonly is set, then the callout is not needed. */}
178
+ {schemaReadonly && !readonly && (
179
+ <Callout.Root valence='info' classNames='mlb-cardSpacingBlock'>
180
+ <Callout.Title>{t('system schema description')}</Callout.Title>
181
+ </Callout.Root>
182
+ )}
183
+
184
+ {/* TODO(burdon): Is the form read-only or just the schema? */}
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}
193
+ readonly={readonly}
194
+ showHeading={showHeading}
195
+ onDelete={handleDelete}
196
+ />
197
+ </Form.Root>
198
+
199
+ {!readonly && !expandedField && (
200
+ <div role='none' className='mlb-cardSpacingBlock'>
201
+ <IconButton
202
+ icon='ph--plus--regular'
203
+ label={t('add property button label')}
204
+ onClick={readonly ? undefined : handleAdd}
205
+ // TODO(burdon): Show field limit in ux (not tooltip).
206
+ disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
207
+ classNames='is-full'
208
+ />
209
+ </div>
210
+ )}
211
+ </div>
212
+ );
213
+ },
214
+ );
215
+
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);
62
220
  const schemaReadonly = !isMutable(schema);
63
221
  const { t } = useTranslation(translationKey);
222
+
64
223
  const projectionModel = useMemo(() => {
65
224
  // Use reactive and mutable version of json schema when schema is mutable.
66
225
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
67
- return new ProjectionModel(jsonSchema, view.projection);
68
- }, [schema, view.projection]);
69
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
70
226
 
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
- }
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);
77
232
 
78
- const serializer = new QuerySerializer();
79
- return serializer.serialize(createExpression(view.query.filter));
80
- }),
81
- Match.exhaustive,
82
- );
233
+ const model = new ProjectionModel({
234
+ registry: atomRegistry,
235
+ view,
236
+ baseSchema: jsonSchema,
237
+ change,
238
+ });
83
239
 
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]);
240
+ return model;
241
+ }, [atomRegistry, schema, view]);
242
+
243
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
95
244
 
96
245
  const handleToggleField = useCallback(
97
246
  (field: FieldType) => {
@@ -100,26 +249,6 @@ export const ViewEditor = ({
100
249
  [readonly],
101
250
  );
102
251
 
103
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
104
-
105
- const handleAdd = useCallback(() => {
106
- invariant(!readonly);
107
- const field = projectionModel.createFieldProjection();
108
- setExpandedField(field.id);
109
- }, [schema, projectionModel, readonly]);
110
-
111
- const handleUpdate = useCallback(
112
- ({ query }: Schema.Schema.Type<typeof viewSchema>) => {
113
- invariant(!readonly);
114
- requestAnimationFrame(() => {
115
- if (serializedQuery !== query && !readonly) {
116
- onQueryChanged?.(query);
117
- }
118
- });
119
- },
120
- [serializedQuery, onQueryChanged, readonly],
121
- );
122
-
123
252
  const handleDelete = useCallback(
124
253
  (fieldId: string) => {
125
254
  invariant(!readonly);
@@ -139,7 +268,9 @@ export const ViewEditor = ({
139
268
  const fields = [...view.projection.fields];
140
269
  const [moved] = fields.splice(fromIndex, 1);
141
270
  fields.splice(toIndex, 0, moved);
142
- view.projection.fields = fields;
271
+ Obj.change(view, (v) => {
272
+ v.projection.fields = fields;
273
+ });
143
274
  },
144
275
  [view.projection.fields, readonly],
145
276
  );
@@ -162,81 +293,42 @@ export const ViewEditor = ({
162
293
  [projectionModel],
163
294
  );
164
295
 
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
- [],
181
- );
182
-
183
296
  return (
184
- <div role='none' className={mx(classNames)}>
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>
189
- )}
190
-
191
- {/* TODO(burdon): Is the form read-only or just the schema? */}
192
- {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
193
- <Form<Schema.Schema.Type<typeof viewSchema>>
194
- autoSave
195
- schema={viewSchema}
196
- values={viewValues}
197
- readonly={readonly ? 'disabled-input' : false}
198
- onSave={handleUpdate}
199
- outerSpacing={outerSpacing}
200
- Custom={custom}
201
- />
202
-
203
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
204
- <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
205
-
206
- <List.Root<FieldType>
207
- items={view.projection.fields}
208
- isItem={Schema.is(FieldSchema)}
209
- getId={(field) => field.id}
210
- onMove={readonly ? undefined : handleMove}
211
- readonly={readonly}
212
- >
213
- {({ items: fields }) => (
214
- <>
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. */}
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
+ <>
240
332
  <List.ItemDeleteButton
241
333
  label={t('delete field label')}
242
334
  autoHide={false}
@@ -247,43 +339,50 @@ export const ViewEditor = ({
247
339
  <IconButton
248
340
  iconOnly
249
341
  variant='ghost'
250
- label={t('toggle expand label', { ns: 'os' })}
342
+ label={t('toggle expand label', { ns: osTranslations })}
251
343
  icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
252
344
  onClick={() => handleToggleField(field)}
345
+ data-testid='field.toggle'
253
346
  />
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
- })}
269
- </div>
270
- </>
271
- )}
272
- </List.Root>
273
- </div>
274
-
275
- {!readonly && !expandedField && (
276
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
277
- <IconButton
278
- icon='ph--plus--regular'
279
- label={t('button add property')}
280
- onClick={readonly ? undefined : handleAdd}
281
- // TODO(burdon): Show field limit in ux (not tooltip).
282
- disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
283
- classNames='is-full'
284
- />
285
- </div>
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
+ </>
286
366
  )}
287
- </div>
367
+ </List.Root>
288
368
  );
289
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],
379
+ );
380
+
381
+ return (
382
+ <Input.Root>
383
+ <FormFieldLabel readonly={readonly} label={label} />
384
+ <QueryForm initialQuery={getValue()} types={types} tags={tags} onChange={handleChange} />
385
+ </Input.Root>
386
+ );
387
+ },
388
+ });
@@ -5,4 +5,5 @@
5
5
  export * from './ControlSection';
6
6
  export * from './FieldEditor';
7
7
  export * from './Form';
8
+ export * from './ObjectPicker';
8
9
  export * from './ViewEditor';
@@ -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='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' />
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';