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

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 +480 -300
  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 +480 -300
  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 -7
  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 -10
  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 -39
  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 +48 -64
  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 +98 -55
  78. package/src/components/ViewEditor/ViewEditor.test.tsx +6 -5
  79. package/src/components/ViewEditor/ViewEditor.tsx +275 -231
  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,30 +2,33 @@
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 * 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 { 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
18
  import { cardSpacing } from '@dxos/react-ui-stack';
16
19
  import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
17
20
  import {
18
- type DataType,
19
21
  FieldSchema,
20
22
  type FieldType,
21
23
  ProjectionModel,
22
24
  VIEW_FIELD_LIMIT,
23
- typenameFromQuery,
25
+ type View,
26
+ getTypenameFromQuery,
24
27
  } from '@dxos/schema';
25
28
 
26
29
  import { translationKey } from '../../translations';
27
30
  import { FieldEditor } from '../FieldEditor';
28
- import { Form, type FormProps, type InputComponent, type InputProps } from '../Form';
31
+ import { Form, type FormProps, type InputComponent, InputHeader, type InputProps } from '../Form';
29
32
 
30
33
  const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
31
34
  const listItemGrid = 'grid grid-cols-subgrid col-span-5';
@@ -33,257 +36,298 @@ const listItemGrid = 'grid grid-cols-subgrid col-span-5';
33
36
  export type ViewEditorProps = ThemedClassName<
34
37
  {
35
38
  schema: Schema.Schema.AnyNoContext;
36
- view: DataType.View;
37
- kind?: 'basic' | 'advanced';
39
+ view: View.View;
40
+ mode?: 'schema' | 'query' | 'named-query';
38
41
  registry?: SchemaRegistry;
39
42
  readonly?: boolean;
40
43
  showHeading?: boolean;
41
- onQueryChanged?: (query: string) => void;
44
+ onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
42
45
  onDelete?: (fieldId: string) => void;
43
- } & Pick<FormProps<any>, 'outerSpacing'>
46
+ } & Pick<FormProps<any>, 'outerSpacing'> &
47
+ Pick<QueryFormProps, 'types' | 'tags'>
44
48
  >;
45
49
 
46
50
  /**
47
51
  * Schema-based object form.
48
52
  */
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);
62
- const schemaReadonly = !isMutable(schema);
63
- const { t } = useTranslation(translationKey);
64
- const projectionModel = useMemo(() => {
65
- // Use reactive and mutable version of json schema when schema is mutable.
66
- 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']>();
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,
68
+ },
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']>();
70
80
 
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
- }
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
+ );
77
90
 
78
- const serializer = new QuerySerializer();
79
- return serializer.serialize(createExpression(view.query.filter));
80
- }),
81
- Match.exhaustive,
82
- );
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
+ });
83
98
 
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]);
99
+ if (mode === 'query' || mode === 'named-query') {
100
+ const name = Schema.Struct({
101
+ name: Schema.optional(Schema.String.annotations({ title: 'Name' })),
102
+ });
95
103
 
96
- const handleToggleField = useCallback(
97
- (field: FieldType) => {
98
- setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
99
- },
100
- [readonly],
101
- );
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
+ }
102
109
 
103
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
110
+ return base.pipe(Schema.mutable);
111
+ }, [mode]);
104
112
 
105
- const handleAdd = useCallback(() => {
106
- invariant(!readonly);
107
- const field = projectionModel.createFieldProjection();
108
- setExpandedField(field.id);
109
- }, [schema, projectionModel, 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
+ );
110
123
 
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
- );
124
+ const handleToggleField = useCallback(
125
+ (field: FieldType) => {
126
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
127
+ },
128
+ [readonly],
129
+ );
130
+
131
+ // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
122
132
 
123
- const handleDelete = useCallback(
124
- (fieldId: string) => {
133
+ const handleAdd = useCallback(() => {
125
134
  invariant(!readonly);
126
- if (fieldId === expandedField) {
127
- setExpandedField(undefined);
128
- }
135
+ const field = projectionModel.createFieldProjection();
136
+ setExpandedField(field.id);
137
+ }, [schema, projectionModel, readonly]);
129
138
 
130
- onDelete?.(fieldId);
131
- },
132
- [expandedField, onDelete, readonly],
133
- );
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
+ }
134
147
 
135
- const handleMove = useCallback(
136
- (fromIndex: number, toIndex: number) => {
137
- invariant(!readonly);
138
- // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
139
- const fields = [...view.projection.fields];
140
- const [moved] = fields.splice(fromIndex, 1);
141
- fields.splice(toIndex, 0, moved);
142
- view.projection.fields = fields;
143
- },
144
- [view.projection.fields, readonly],
145
- );
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
+ );
146
154
 
147
- const handleClose = useCallback(() => setExpandedField(undefined), []);
155
+ const handleDelete = useCallback(
156
+ (fieldId: string) => {
157
+ invariant(!readonly);
158
+ if (fieldId === expandedField) {
159
+ setExpandedField(undefined);
160
+ }
148
161
 
149
- const handleHide = useCallback(
150
- (fieldId: string) => {
151
- setExpandedField(undefined);
152
- projectionModel.hideFieldProjection(fieldId);
153
- },
154
- [projectionModel],
155
- );
162
+ onDelete?.(fieldId);
163
+ },
164
+ [expandedField, onDelete, readonly],
165
+ );
156
166
 
157
- const handleShow = useCallback(
158
- (property: string) => {
159
- setExpandedField(undefined);
160
- projectionModel.showFieldProjection(property as JsonProp);
161
- },
162
- [projectionModel],
163
- );
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
+ );
164
178
 
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
- );
179
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
174
180
 
175
- // TODO(wittjosiah): Add label & input styles.
176
- return <QueryEditor space={space} initialValue={props.getValue()} onChange={handleChange} />;
177
- },
178
- }
179
- : {},
180
- [],
181
- );
181
+ const handleHide = useCallback(
182
+ (fieldId: string) => {
183
+ setExpandedField(undefined);
184
+ projectionModel.hideFieldProjection(fieldId);
185
+ },
186
+ [projectionModel],
187
+ );
188
+
189
+ const handleShow = useCallback(
190
+ (property: string) => {
191
+ setExpandedField(undefined);
192
+ projectionModel.showFieldProjection(property as JsonProp);
193
+ },
194
+ [projectionModel],
195
+ );
182
196
 
183
- 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
- )}
197
+ const custom = useMemo(() => (mode === 'query' ? customFields({ types, tags }) : undefined), [types, tags, mode]);
190
198
 
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
- />
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
+ )}
202
206
 
203
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
204
- <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
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
+ />
205
218
 
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. */}
240
- <List.ItemDeleteButton
241
- label={t('delete field label')}
242
- autoHide={false}
243
- disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
244
- onClick={() => handleDelete(field.id)}
245
- data-testid='field.delete'
246
- />
247
- <IconButton
248
- iconOnly
249
- variant='ghost'
250
- label={t('toggle expand label', { ns: 'os' })}
251
- icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
252
- onClick={() => handleToggleField(field)}
253
- />
254
- </div>
255
- {expandedField === field.id && (
256
- <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
257
- <FieldEditor
258
- readonly={readonly || schemaReadonly ? 'disabled-input' : false}
259
- projection={projectionModel}
260
- field={field}
261
- registry={registry}
262
- onSave={handleClose}
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))}
263
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
+ )}
264
281
  </div>
265
- )}
266
- </List.Item>
267
- );
268
- })}
269
- </div>
270
- </>
271
- )}
272
- </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
+ )}
273
314
  </div>
315
+ );
316
+ },
317
+ );
274
318
 
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>
286
- )}
287
- </div>
288
- );
289
- };
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],