@dxos/react-ui-form 0.8.4-main.5ea62a8 → 0.8.4-main.72ec0f3

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 (89) hide show
  1. package/dist/lib/browser/index.mjs +492 -287
  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 +492 -287
  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.stories.d.ts +5 -5
  8. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +5 -5
  11. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Form.d.ts +4 -3
  14. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.stories.d.ts +15 -15
  16. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/FormContent.d.ts +5 -5
  18. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormContext.d.ts +2 -2
  20. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Input.d.ts +1 -1
  22. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/RefField.d.ts +10 -6
  24. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +5 -5
  26. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/factory.d.ts +1 -1
  28. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  29. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  30. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  31. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +81 -0
  32. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  33. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  34. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  35. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +20 -6
  36. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  37. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +10 -9
  38. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  39. package/dist/types/src/components/index.d.ts +1 -0
  40. package/dist/types/src/components/index.d.ts.map +1 -1
  41. package/dist/types/src/hooks/useForm.d.ts +2 -2
  42. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  43. package/dist/types/src/hooks/useQueryRefOptions.d.ts +9 -3
  44. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -1
  46. package/dist/types/src/translations.d.ts +4 -4
  47. package/dist/types/src/util/field.d.ts +1 -1
  48. package/dist/types/src/util/field.d.ts.map +1 -1
  49. package/dist/types/src/util/formatting.d.ts +1 -1
  50. package/dist/types/src/util/formatting.d.ts.map +1 -1
  51. package/dist/types/src/util/refs.d.ts +1 -1
  52. package/dist/types/src/util/refs.d.ts.map +1 -1
  53. package/dist/types/src/util/schema.d.ts +1 -1
  54. package/dist/types/src/util/schema.d.ts.map +1 -1
  55. package/dist/types/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +40 -37
  57. package/src/components/ControlSection/ControlSection.stories.tsx +4 -5
  58. package/src/components/ControlSection/ControlSection.tsx +1 -1
  59. package/src/components/FieldEditor/FieldEditor.stories.tsx +6 -5
  60. package/src/components/FieldEditor/FieldEditor.tsx +5 -2
  61. package/src/components/Form/ArrayField.tsx +4 -3
  62. package/src/components/Form/Defaults.tsx +1 -1
  63. package/src/components/Form/Form.stories.tsx +35 -35
  64. package/src/components/Form/Form.tsx +16 -6
  65. package/src/components/Form/FormActions.tsx +2 -2
  66. package/src/components/Form/FormContent.tsx +82 -22
  67. package/src/components/Form/FormContext.tsx +3 -3
  68. package/src/components/Form/Input.tsx +1 -1
  69. package/src/components/Form/RefField.tsx +50 -66
  70. package/src/components/Form/custom/GeoPointInput.tsx +1 -1
  71. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +5 -6
  72. package/src/components/Form/custom/SelectOptionsInput.tsx +2 -2
  73. package/src/components/Form/factory.tsx +1 -1
  74. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  75. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  76. package/src/components/ObjectPicker/index.ts +5 -0
  77. package/src/components/ViewEditor/ViewEditor.stories.tsx +114 -48
  78. package/src/components/ViewEditor/ViewEditor.test.tsx +6 -5
  79. package/src/components/ViewEditor/ViewEditor.tsx +288 -208
  80. package/src/components/index.ts +1 -0
  81. package/src/components/testing.tsx +2 -2
  82. package/src/hooks/useForm.ts +7 -4
  83. package/src/hooks/useQueryRefOptions.ts +15 -6
  84. package/src/hooks/useRefQueryLookupHandler.ts +3 -3
  85. package/src/translations.ts +4 -4
  86. package/src/util/field.ts +1 -1
  87. package/src/util/formatting.ts +1 -1
  88. package/src/util/refs.ts +7 -7
  89. package/src/util/schema.ts +2 -4
@@ -2,252 +2,332 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Schema } from 'effect';
6
- import React, { useCallback, useMemo, useState } from 'react';
5
+ import * as Array from 'effect/Array';
6
+ import * as Match from 'effect/Match';
7
+ import * as Option from 'effect/Option';
8
+ import * as Schema from 'effect/Schema';
9
+ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react';
7
10
 
8
- import { type SchemaRegistry } from '@dxos/echo-db';
9
- import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo-schema';
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';
10
14
  import { invariant } from '@dxos/invariant';
11
- import { Callout, IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
15
+ import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
+ import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
12
17
  import { List } from '@dxos/react-ui-list';
13
18
  import { cardSpacing } from '@dxos/react-ui-stack';
14
19
  import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
15
- import { type DataType, FieldSchema, type FieldType, ProjectionModel, VIEW_FIELD_LIMIT } from '@dxos/schema';
20
+ import {
21
+ FieldSchema,
22
+ type FieldType,
23
+ ProjectionModel,
24
+ VIEW_FIELD_LIMIT,
25
+ type View,
26
+ getTypenameFromQuery,
27
+ } from '@dxos/schema';
16
28
 
17
29
  import { translationKey } from '../../translations';
18
30
  import { FieldEditor } from '../FieldEditor';
19
- import { Form, type FormProps } from '../Form';
31
+ import { Form, type FormProps, type InputComponent, InputHeader, type InputProps } from '../Form';
20
32
 
21
33
  const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
22
34
  const listItemGrid = 'grid grid-cols-subgrid col-span-5';
23
35
 
24
- const ViewMetaSchema = Schema.Struct({
25
- typename: Format.URL.annotations({
26
- title: 'Record type',
27
- }),
28
- }).pipe(Schema.mutable);
29
-
30
- type ViewMetaType = Schema.Schema.Type<typeof ViewMetaSchema>;
31
-
32
36
  export type ViewEditorProps = ThemedClassName<
33
37
  {
34
38
  schema: Schema.Schema.AnyNoContext;
35
- view: DataType.View;
39
+ view: View.View;
40
+ mode?: 'schema' | 'query' | 'named-query';
36
41
  registry?: SchemaRegistry;
37
42
  readonly?: boolean;
38
43
  showHeading?: boolean;
39
- onTypenameChanged?: (typename: string) => void;
44
+ onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
40
45
  onDelete?: (fieldId: string) => void;
41
- } & Pick<FormProps<any>, 'outerSpacing'>
46
+ } & Pick<FormProps<any>, 'outerSpacing'> &
47
+ Pick<QueryFormProps, 'types' | 'tags'>
42
48
  >;
43
49
 
44
50
  /**
45
51
  * Schema-based object form.
46
52
  */
47
- export const ViewEditor = ({
48
- classNames,
49
- schema,
50
- view,
51
- registry,
52
- readonly,
53
- showHeading = false,
54
- onTypenameChanged,
55
- onDelete,
56
- outerSpacing = true,
57
- }: ViewEditorProps) => {
58
- const schemaReadonly = !isMutable(schema);
59
- const { t } = useTranslation(translationKey);
60
- const projectionModel = useMemo(() => {
61
- // Use reactive and mutable version of json schema when schema is mutable.
62
- const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
63
- return new ProjectionModel(jsonSchema, view.projection);
64
- }, [schema, view.projection]);
65
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
66
-
67
- // TODO(burdon): Should be reactive.
68
- const viewValues = useMemo(() => {
69
- return {
70
- // TODO(burdon): Need to warn user of possible consequences of editing.
71
- // TODO(burdon): Settings should have domain name owned by user.
72
- typename: view.query.typename,
73
- };
74
- }, [view.query.typename]);
75
-
76
- const handleToggleField = useCallback(
77
- (field: FieldType) => {
78
- setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
53
+ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
54
+ (
55
+ {
56
+ classNames,
57
+ schema,
58
+ view,
59
+ mode = 'schema',
60
+ registry,
61
+ readonly,
62
+ showHeading = false,
63
+ types,
64
+ tags,
65
+ onQueryChanged,
66
+ onDelete,
67
+ outerSpacing = true,
79
68
  },
80
- [readonly],
81
- );
69
+ forwardedRef,
70
+ ) => {
71
+ const schemaReadonly = !isMutable(schema);
72
+ const { t } = useTranslation(translationKey);
73
+ const projectionModel = useMemo(() => {
74
+ // Use reactive and mutable version of json schema when schema is mutable.
75
+ const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
76
+ return new ProjectionModel(jsonSchema, view.projection);
77
+ }, [schema, JSON.stringify(view.projection)]);
78
+ useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
79
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
80
+
81
+ const queueTarget = Match.value(view.query.ast).pipe(
82
+ Match.when({ type: 'options' }, ({ options }) => {
83
+ return Option.fromNullable(options.queues).pipe(
84
+ Option.flatMap((queues) => Array.head(queues)),
85
+ Option.getOrUndefined,
86
+ );
87
+ }),
88
+ Match.orElse(() => undefined),
89
+ );
90
+
91
+ const viewSchema = useMemo(() => {
92
+ const base = Schema.Struct({
93
+ query:
94
+ mode === 'schema'
95
+ ? Format.URL.annotations({ title: 'Record type' })
96
+ : QueryAST.Query.annotations({ title: 'Query' }),
97
+ });
98
+
99
+ if (mode === 'query' || mode === 'named-query') {
100
+ const name = Schema.Struct({
101
+ name: Schema.optional(Schema.String.annotations({ title: 'Name' })),
102
+ });
103
+
104
+ return Schema.Struct({
105
+ ...(mode === 'named-query' ? { ...name.fields, ...base.fields } : base.fields),
106
+ target: Schema.optional(Schema.String.annotations({ title: 'Target Queue' })),
107
+ }).pipe(Schema.mutable);
108
+ }
109
+
110
+ return base.pipe(Schema.mutable);
111
+ }, [mode]);
82
112
 
83
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
113
+ // TODO(burdon): Need to warn user of possible consequences of editing.
114
+ // TODO(burdon): Settings should have domain name owned by user.
115
+ const viewValues = useMemo(
116
+ () => ({
117
+ name: view.name,
118
+ query: mode === 'schema' ? getTypenameFromQuery(view.query.ast) : view.query.ast,
119
+ target: queueTarget,
120
+ }),
121
+ [view.name, mode, view.query.ast, queueTarget],
122
+ );
84
123
 
85
- const handleAdd = useCallback(() => {
86
- invariant(!readonly);
87
- const field = projectionModel.createFieldProjection();
88
- setExpandedField(field.id);
89
- }, [schema, projectionModel, readonly]);
124
+ const handleToggleField = useCallback(
125
+ (field: FieldType) => {
126
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
127
+ },
128
+ [readonly],
129
+ );
90
130
 
91
- const handleUpdate = useCallback(
92
- ({ typename }: ViewMetaType) => {
131
+ // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
132
+
133
+ const handleAdd = useCallback(() => {
93
134
  invariant(!readonly);
94
- requestAnimationFrame(() => {
95
- if (view.query.typename !== typename && !readonly) {
96
- onTypenameChanged?.(typename);
135
+ const field = projectionModel.createFieldProjection();
136
+ setExpandedField(field.id);
137
+ }, [schema, projectionModel, readonly]);
138
+
139
+ const handleUpdate = useCallback(
140
+ (values: any) => {
141
+ invariant(!readonly);
142
+ requestAnimationFrame(() => {
143
+ if ('name' in values && view.name !== values.name) {
144
+ view.name = values.name;
145
+ return;
146
+ }
147
+
148
+ const query = mode === 'schema' ? Query.select(Filter.typename(values.query)).ast : values.query;
149
+ onQueryChanged?.(query, values.target);
150
+ });
151
+ },
152
+ [onQueryChanged, readonly, view, queueTarget, mode],
153
+ );
154
+
155
+ const handleDelete = useCallback(
156
+ (fieldId: string) => {
157
+ invariant(!readonly);
158
+ if (fieldId === expandedField) {
159
+ setExpandedField(undefined);
97
160
  }
98
- });
99
- },
100
- [view.query.typename, onTypenameChanged, readonly],
101
- );
102
161
 
103
- const handleDelete = useCallback(
104
- (fieldId: string) => {
105
- invariant(!readonly);
106
- if (fieldId === expandedField) {
162
+ onDelete?.(fieldId);
163
+ },
164
+ [expandedField, onDelete, readonly],
165
+ );
166
+
167
+ const handleMove = useCallback(
168
+ (fromIndex: number, toIndex: number) => {
169
+ invariant(!readonly);
170
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
171
+ const fields = [...view.projection.fields];
172
+ const [moved] = fields.splice(fromIndex, 1);
173
+ fields.splice(toIndex, 0, moved);
174
+ view.projection.fields = fields;
175
+ },
176
+ [view.projection.fields, readonly],
177
+ );
178
+
179
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
180
+
181
+ const handleHide = useCallback(
182
+ (fieldId: string) => {
107
183
  setExpandedField(undefined);
108
- }
184
+ projectionModel.hideFieldProjection(fieldId);
185
+ },
186
+ [projectionModel],
187
+ );
109
188
 
110
- onDelete?.(fieldId);
111
- },
112
- [expandedField, onDelete, readonly],
113
- );
189
+ const handleShow = useCallback(
190
+ (property: string) => {
191
+ setExpandedField(undefined);
192
+ projectionModel.showFieldProjection(property as JsonProp);
193
+ },
194
+ [projectionModel],
195
+ );
114
196
 
115
- const handleMove = useCallback(
116
- (fromIndex: number, toIndex: number) => {
117
- invariant(!readonly);
118
- // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
119
- const fields = [...view.projection.fields];
120
- const [moved] = fields.splice(fromIndex, 1);
121
- fields.splice(toIndex, 0, moved);
122
- view.projection.fields = fields;
123
- },
124
- [view.projection.fields, readonly],
125
- );
197
+ const custom = useMemo(() => (mode === 'query' ? customFields({ types, tags }) : undefined), [types, tags, mode]);
126
198
 
127
- const handleClose = useCallback(() => setExpandedField(undefined), []);
199
+ return (
200
+ <div role='none' className={mx(classNames)}>
201
+ {schemaReadonly && mode === 'schema' && (
202
+ <Callout.Root valence='info' classNames={['mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}>
203
+ <Callout.Title>{t('system schema description')}</Callout.Title>
204
+ </Callout.Root>
205
+ )}
128
206
 
129
- const handleHide = useCallback(
130
- (fieldId: string) => {
131
- setExpandedField(undefined);
132
- projectionModel.hideFieldProjection(fieldId);
133
- },
134
- [projectionModel],
135
- );
207
+ {/* TODO(burdon): Is the form read-only or just the schema? */}
208
+ {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
209
+ <Form<Schema.Schema.Type<typeof viewSchema>>
210
+ autoSave
211
+ schema={viewSchema}
212
+ values={viewValues}
213
+ readonly={readonly ? 'disabled-input' : false}
214
+ onSave={handleUpdate}
215
+ outerSpacing={outerSpacing}
216
+ Custom={custom}
217
+ />
136
218
 
137
- const handleShow = useCallback(
138
- (property: string) => {
139
- setExpandedField(undefined);
140
- projectionModel.showFieldProjection(property as JsonProp);
141
- },
142
- [projectionModel],
143
- );
144
-
145
- return (
146
- <div role='none' className={mx(classNames)}>
147
- {schemaReadonly && (
148
- <Callout.Root
149
- valence='info'
150
- classNames={['is-full mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}
151
- >
152
- <Callout.Title>{t('system schema description')}</Callout.Title>
153
- </Callout.Root>
154
- )}
155
-
156
- {/* TODO(burdon): Is the form read-only or just the schema? */}
157
- {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
158
- <Form<ViewMetaType>
159
- autoSave
160
- schema={ViewMetaSchema}
161
- values={viewValues}
162
- readonly={readonly ? 'disabled-input' : false}
163
- onSave={handleUpdate}
164
- outerSpacing={outerSpacing}
165
- />
166
-
167
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
168
- <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
169
-
170
- <List.Root<FieldType>
171
- items={view.projection.fields}
172
- isItem={Schema.is(FieldSchema)}
173
- getId={(field) => field.id}
174
- onMove={readonly ? undefined : handleMove}
175
- readonly={readonly}
176
- >
177
- {({ items: fields }) => (
178
- <>
179
- {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
180
- <div role='list' className={listGrid}>
181
- {fields?.map((field) => {
182
- const hidden = field.visible === false;
183
- return (
184
- <List.Item<FieldType>
185
- key={field.id}
186
- item={field}
187
- classNames={listItemGrid}
188
- aria-expanded={expandedField === field.id}
189
- >
190
- <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
191
- <List.ItemDragHandle disabled={readonly} />
192
- <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
193
- {field.path}
194
- </List.ItemTitle>
195
- <List.ItemButton
196
- label={t(hidden ? 'show field label' : 'hide field label')}
197
- data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
198
- icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
199
- autoHide={false}
200
- disabled={readonly || (!hidden && projectionModel.fields.length <= 1)}
201
- onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
202
- />
203
- {/* TODO(burdon): Remove unless implement undo. */}
204
- <List.ItemDeleteButton
205
- label={t('delete field label')}
206
- autoHide={false}
207
- disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
208
- onClick={() => handleDelete(field.id)}
209
- data-testid='field.delete'
210
- />
211
- <IconButton
212
- iconOnly
213
- variant='ghost'
214
- label={t('toggle expand label', { ns: 'os' })}
215
- icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
216
- onClick={() => handleToggleField(field)}
217
- />
218
- </div>
219
- {expandedField === field.id && (
220
- <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
221
- <FieldEditor
222
- readonly={readonly || schemaReadonly ? 'disabled-input' : false}
223
- projection={projectionModel}
224
- field={field}
225
- registry={registry}
226
- onSave={handleClose}
219
+ <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
220
+ <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
221
+
222
+ <List.Root<FieldType>
223
+ items={view.projection.fields}
224
+ isItem={Schema.is(FieldSchema)}
225
+ getId={(field) => field.id}
226
+ onMove={readonly ? undefined : handleMove}
227
+ readonly={readonly}
228
+ >
229
+ {({ items: fields }) => (
230
+ <>
231
+ {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
232
+ <div role='list' className={listGrid}>
233
+ {fields?.map((field) => {
234
+ const hidden = field.visible === false;
235
+ return (
236
+ <List.Item<FieldType>
237
+ key={field.id}
238
+ item={field}
239
+ classNames={listItemGrid}
240
+ aria-expanded={expandedField === field.id}
241
+ >
242
+ <div
243
+ role='none'
244
+ className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}
245
+ >
246
+ <List.ItemDragHandle disabled={readonly} />
247
+ <List.ItemTitle
248
+ classNames={hidden && 'text-subdued'}
249
+ onClick={() => handleToggleField(field)}
250
+ >
251
+ {field.path}
252
+ </List.ItemTitle>
253
+ <List.ItemButton
254
+ label={t(hidden ? 'show field label' : 'hide field label')}
255
+ data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
256
+ icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
257
+ autoHide={false}
258
+ disabled={readonly || (!hidden && projectionModel.fields.length <= 1)}
259
+ onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
227
260
  />
261
+ {mode === 'schema' && (
262
+ <>
263
+ <List.ItemDeleteButton
264
+ label={t('delete field label')}
265
+ autoHide={false}
266
+ disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
267
+ onClick={() => handleDelete(field.id)}
268
+ data-testid='field.delete'
269
+ />
270
+ <IconButton
271
+ iconOnly
272
+ variant='ghost'
273
+ label={t('toggle expand label', { ns: 'os' })}
274
+ icon={
275
+ expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'
276
+ }
277
+ onClick={() => handleToggleField(field)}
278
+ />
279
+ </>
280
+ )}
228
281
  </div>
229
- )}
230
- </List.Item>
231
- );
232
- })}
233
- </div>
234
- </>
235
- )}
236
- </List.Root>
282
+ {expandedField === field.id && mode === 'schema' && (
283
+ <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
284
+ <FieldEditor
285
+ readonly={readonly || schemaReadonly ? 'disabled-input' : false}
286
+ projection={projectionModel}
287
+ field={field}
288
+ registry={registry}
289
+ onSave={handleClose}
290
+ />
291
+ </div>
292
+ )}
293
+ </List.Item>
294
+ );
295
+ })}
296
+ </div>
297
+ </>
298
+ )}
299
+ </List.Root>
300
+ </div>
301
+
302
+ {!readonly && !expandedField && mode === 'schema' && (
303
+ <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
304
+ <IconButton
305
+ icon='ph--plus--regular'
306
+ label={t('add property button')}
307
+ onClick={readonly ? undefined : handleAdd}
308
+ // TODO(burdon): Show field limit in ux (not tooltip).
309
+ disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
310
+ classNames='is-full'
311
+ />
312
+ </div>
313
+ )}
237
314
  </div>
315
+ );
316
+ },
317
+ );
238
318
 
239
- {!readonly && !expandedField && (
240
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
241
- <IconButton
242
- icon='ph--plus--regular'
243
- label={t('button add property')}
244
- onClick={readonly ? undefined : handleAdd}
245
- // TODO(burdon): Show field limit in ux (not tooltip).
246
- disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
247
- classNames='is-full'
248
- />
249
- </div>
250
- )}
251
- </div>
252
- );
253
- };
319
+ const customFields = ({ types, tags }: Pick<ViewEditorProps, 'types' | 'tags'>): Record<string, InputComponent> => ({
320
+ query: (props: InputProps) => {
321
+ const handleChange = useCallback(
322
+ (query: Query.Any) => props.onValueChange('object', query.ast),
323
+ [props.onValueChange],
324
+ );
325
+
326
+ return (
327
+ <Input.Root>
328
+ <InputHeader label={props.label} />
329
+ <QueryForm initialQuery={props.getValue()} types={types} tags={tags} onChange={handleChange} />
330
+ </Input.Root>
331
+ );
332
+ },
333
+ });
@@ -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';
@@ -13,9 +13,9 @@ 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'>
16
+ <div className='is-full bs-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
17
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' />
18
+ <JsonFilter data={json} classNames='is-full text-xs' testId='debug' />
19
19
  </div>
20
20
  );
21
21
  };
@@ -2,10 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type Schema, SchemaAST } from 'effect';
5
+ import type * as Schema from 'effect/Schema';
6
+ import * as SchemaAST from 'effect/SchemaAST';
6
7
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
8
 
8
- import { type BaseObject, getValue, setValue } from '@dxos/echo-schema';
9
+ import { type BaseObject, getValue, setValue } from '@dxos/echo/internal';
9
10
  import { type JsonPath, type SimpleType, createJsonPath, fromEffectValidationPath } from '@dxos/effect';
10
11
  import { invariant } from '@dxos/invariant';
11
12
  import { log } from '@dxos/log';
@@ -203,6 +204,7 @@ export const useForm = <T extends BaseObject>({
203
204
  [values],
204
205
  );
205
206
 
207
+ // TODO(wittjosiah): This is causing the entire form to re-render on every change.
206
208
  const onValueChange = useCallback<FormHandler<T>['onValueChange']>(
207
209
  (path: (string | number)[], type: SimpleType, value: any) => {
208
210
  const jsonPath = createJsonPath(path);
@@ -218,12 +220,13 @@ export const useForm = <T extends BaseObject>({
218
220
 
219
221
  const newValues = { ...setValue(values, jsonPath, parsedValue) };
220
222
  setValues(newValues);
221
- setChanged((prev) => ({ ...prev, [jsonPath]: true }));
223
+ const newChanged = { ...changed, [jsonPath]: true };
224
+ setChanged(newChanged);
222
225
  onValuesChanged?.(newValues);
223
226
 
224
227
  const isValid = validate(newValues);
225
228
  if (isValid && onValid) {
226
- onValid(newValues, { changed });
229
+ onValid(newValues, { changed: newChanged });
227
230
  }
228
231
  },
229
232
  [values, onValuesChanged, validate, onValid, changed],
@@ -2,26 +2,35 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { useEffect, useState } from 'react';
5
+ import { useCallback, useEffect, useState } from 'react';
6
6
 
7
- import { type TypeAnnotation } from '@dxos/echo-schema';
7
+ import { type TypeAnnotation } from '@dxos/echo/internal';
8
8
  import { type DXN } from '@dxos/keys';
9
9
  import { log } from '@dxos/log';
10
- import { type TagPickerItemData } from '@dxos/react-ui-tag-picker';
10
+ import { type Palette } from '@dxos/react-ui-types';
11
11
  import { type MaybePromise } from '@dxos/util';
12
12
 
13
13
  export type RefOption = { dxn: DXN; label?: string };
14
14
  export type QueryRefOptions = (type: TypeAnnotation) => MaybePromise<RefOption[]>;
15
15
 
16
+ export type QueryTag = {
17
+ id: string;
18
+ label: string;
19
+ hue?: Palette;
20
+ };
21
+
16
22
  type UseQueryRefOptionsProps = { refTypeInfo: TypeAnnotation | undefined; onQueryRefOptions?: QueryRefOptions };
17
23
 
18
24
  /**
19
25
  * Hook to query reference options based on type information.
20
26
  * Used internally within forms to fetch and format reference options for reference fields.
21
27
  */
28
+ // TODO(wittjosiah): This should be a reactive query so that the options are always up to date.
22
29
  export const useQueryRefOptions = ({ refTypeInfo, onQueryRefOptions }: UseQueryRefOptionsProps) => {
23
- const [options, setOptions] = useState<TagPickerItemData[]>([]);
30
+ const [options, setOptions] = useState<QueryTag[]>([]);
24
31
  const [loading, setLoading] = useState(false);
32
+ const [state, setState] = useState({});
33
+ const update = useCallback(() => setState({}), []);
25
34
 
26
35
  useEffect(() => {
27
36
  if (!refTypeInfo || !onQueryRefOptions) {
@@ -47,7 +56,7 @@ export const useQueryRefOptions = ({ refTypeInfo, onQueryRefOptions }: UseQueryR
47
56
  };
48
57
 
49
58
  void fetchOptions();
50
- }, [refTypeInfo, onQueryRefOptions]);
59
+ }, [refTypeInfo, onQueryRefOptions, state]);
51
60
 
52
- return { options, loading };
61
+ return { options, update, loading };
53
62
  };