@dxos/react-ui-form 0.8.4-main.fd6878d → 0.8.4-main.fffef41

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 (115) hide show
  1. package/dist/lib/browser/index.mjs +806 -572
  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 +806 -572
  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 +80 -5
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +1 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +75 -5
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/ArrayField.d.ts +2 -2
  16. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Defaults.d.ts +5 -5
  18. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.d.ts +6 -4
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +128 -32
  22. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormContent.d.ts +16 -10
  24. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormContext.d.ts +2 -2
  26. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/Input.d.ts +5 -2
  28. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/RefField.d.ts +14 -4
  30. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -1
  32. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  33. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +1 -1
  34. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +75 -4
  35. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -1
  37. package/dist/types/src/components/Form/factory.d.ts +1 -1
  38. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  39. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  40. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  41. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +81 -0
  42. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  44. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  45. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +20 -6
  46. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  47. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +79 -8
  48. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  49. package/dist/types/src/components/index.d.ts +1 -1
  50. package/dist/types/src/components/index.d.ts.map +1 -1
  51. package/dist/types/src/components/testing.d.ts +6 -3
  52. package/dist/types/src/components/testing.d.ts.map +1 -1
  53. package/dist/types/src/hooks/useForm.d.ts +2 -2
  54. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useQueryRefOptions.d.ts +9 -3
  56. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -1
  58. package/dist/types/src/translations.d.ts +11 -7
  59. package/dist/types/src/translations.d.ts.map +1 -1
  60. package/dist/types/src/util/field.d.ts +1 -1
  61. package/dist/types/src/util/field.d.ts.map +1 -1
  62. package/dist/types/src/util/formatting.d.ts +1 -1
  63. package/dist/types/src/util/formatting.d.ts.map +1 -1
  64. package/dist/types/src/util/refs.d.ts +1 -1
  65. package/dist/types/src/util/refs.d.ts.map +1 -1
  66. package/dist/types/src/util/schema.d.ts +1 -1
  67. package/dist/types/src/util/schema.d.ts.map +1 -1
  68. package/dist/types/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +41 -36
  70. package/src/components/ControlSection/ControlSection.stories.tsx +10 -14
  71. package/src/components/ControlSection/ControlSection.tsx +26 -12
  72. package/src/components/FieldEditor/FieldEditor.stories.tsx +10 -11
  73. package/src/components/FieldEditor/FieldEditor.tsx +12 -14
  74. package/src/components/Form/ArrayField.tsx +6 -6
  75. package/src/components/Form/Defaults.tsx +21 -20
  76. package/src/components/Form/Form.stories.tsx +132 -97
  77. package/src/components/Form/Form.tsx +27 -14
  78. package/src/components/Form/FormActions.tsx +2 -2
  79. package/src/components/Form/FormContent.tsx +166 -45
  80. package/src/components/Form/FormContext.tsx +3 -3
  81. package/src/components/Form/Input.tsx +5 -2
  82. package/src/components/Form/RefField.tsx +107 -38
  83. package/src/components/Form/custom/GeoPointInput.tsx +28 -20
  84. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +9 -10
  85. package/src/components/Form/custom/SelectOptionsInput.tsx +10 -10
  86. package/src/components/Form/custom/TupleInput.tsx +2 -2
  87. package/src/components/Form/factory.tsx +1 -1
  88. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  89. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  90. package/src/components/ObjectPicker/index.ts +5 -0
  91. package/src/components/ViewEditor/ViewEditor.stories.tsx +123 -49
  92. package/src/components/ViewEditor/ViewEditor.test.tsx +8 -11
  93. package/src/components/ViewEditor/ViewEditor.tsx +281 -233
  94. package/src/components/index.ts +1 -2
  95. package/src/components/testing.tsx +15 -9
  96. package/src/hooks/useForm.ts +7 -4
  97. package/src/hooks/useQueryRefOptions.ts +15 -6
  98. package/src/hooks/useRefQueryLookupHandler.ts +3 -3
  99. package/src/translations.ts +13 -8
  100. package/src/util/field.ts +9 -19
  101. package/src/util/formatting.ts +1 -1
  102. package/src/util/refs.ts +7 -7
  103. package/src/util/schema.ts +2 -4
  104. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  105. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  106. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  107. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  108. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  109. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  110. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  111. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  112. package/src/components/deprecated/Form.stories.tsx +0 -63
  113. package/src/components/deprecated/Form.tsx +0 -119
  114. package/src/components/deprecated/FormInput.tsx +0 -71
  115. package/src/components/deprecated/index.ts +0 -5
@@ -2,284 +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: _readonly,
53
- showHeading = false,
54
- onTypenameChanged,
55
- onDelete,
56
- outerSpacing = true,
57
- }: ViewEditorProps) => {
58
- const { t } = useTranslation(translationKey);
59
- const projection = useMemo(() => {
60
- // Use reactive and mutable version of json schema when schema is mutable.
61
- const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
62
- return new ProjectionModel(jsonSchema, view.projection);
63
- }, [schema, view.projection]);
64
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
65
- const readonly = _readonly || !isMutable(schema);
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']>();
82
80
 
83
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
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
+ );
84
90
 
85
- const handleAdd = useCallback(() => {
86
- invariant(!readonly);
87
- const field = projection.createFieldProjection();
88
- setExpandedField(field.id);
89
- }, [schema, projection, readonly]);
90
-
91
- const handleUpdate = useCallback(
92
- ({ typename }: ViewMetaType) => {
93
- invariant(!readonly);
94
- requestAnimationFrame(() => {
95
- if (view.query.typename !== typename && !readonly) {
96
- onTypenameChanged?.(typename);
97
- }
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' }),
98
97
  });
99
- },
100
- [view.query.typename, onTypenameChanged, readonly],
101
- );
102
98
 
103
- const handleDelete = useCallback(
104
- (fieldId: string) => {
105
- invariant(!readonly);
106
- if (fieldId === expandedField) {
107
- setExpandedField(undefined);
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
108
  }
109
109
 
110
- onDelete?.(fieldId);
111
- },
112
- [expandedField, onDelete, readonly],
113
- );
110
+ return base.pipe(Schema.mutable);
111
+ }, [mode]);
112
+
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
+ );
123
+
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?
114
132
 
115
- const handleMove = useCallback(
116
- (fromIndex: number, toIndex: number) => {
133
+ const handleAdd = useCallback(() => {
117
134
  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
- );
135
+ const field = projectionModel.createFieldProjection();
136
+ setExpandedField(field.id);
137
+ }, [schema, projectionModel, readonly]);
126
138
 
127
- const handleClose = useCallback(() => setExpandedField(undefined), []);
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
+ }
128
147
 
129
- const hiddenProperties = projection.getHiddenProperties();
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
+ );
130
154
 
131
- const handleHide = useCallback(
132
- (fieldId: string) => {
133
- setExpandedField(undefined);
134
- projection.hideFieldProjection(fieldId);
135
- },
136
- [projection],
137
- );
155
+ const handleDelete = useCallback(
156
+ (fieldId: string) => {
157
+ invariant(!readonly);
158
+ if (fieldId === expandedField) {
159
+ setExpandedField(undefined);
160
+ }
138
161
 
139
- const handleShow = useCallback(
140
- (property: string) => {
141
- setExpandedField(undefined);
142
- projection.showFieldProjection(property as JsonProp);
143
- },
144
- [projection],
145
- );
162
+ onDelete?.(fieldId);
163
+ },
164
+ [expandedField, onDelete, readonly],
165
+ );
146
166
 
147
- return (
148
- <div role='none' className={mx(classNames)}>
149
- {readonly && (
150
- <Callout.Root
151
- valence='info'
152
- classNames={['is-full mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}
153
- >
154
- <Callout.Title>{t('system schema description')}</Callout.Title>
155
- </Callout.Root>
156
- )}
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
+ );
157
178
 
158
- {/* TODO(burdon): Is the form read-only or just the schema? */}
159
- {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
160
- <Form<ViewMetaType>
161
- autoSave
162
- schema={ViewMetaSchema}
163
- values={viewValues}
164
- readonly={readonly}
165
- onSave={handleUpdate}
166
- outerSpacing={outerSpacing}
167
- />
179
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
168
180
 
169
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
170
- <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
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
+ );
171
196
 
172
- <List.Root<FieldType>
173
- items={view.projection.fields}
174
- isItem={Schema.is(FieldSchema)}
175
- getId={(field) => field.id}
176
- onMove={readonly ? undefined : handleMove}
177
- readonly={readonly}
178
- >
179
- {({ items: fields }) => (
180
- <>
181
- {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
197
+ const custom = useMemo(() => (mode === 'query' ? customFields({ types, tags }) : undefined), [types, tags, mode]);
182
198
 
183
- <div role='list' className={listGrid}>
184
- {fields?.map((field) => (
185
- <List.Item<FieldType>
186
- key={field.id}
187
- item={field}
188
- classNames={listItemGrid}
189
- aria-expanded={expandedField === field.id}
190
- >
191
- <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
192
- <List.ItemDragHandle disabled={readonly} />
193
- <List.ItemTitle onClick={() => handleToggleField(field)}>{field.path}</List.ItemTitle>
194
- <List.ItemButton
195
- label={t('hide field label')}
196
- data-testid='hide-field-button'
197
- icon='ph--eye-slash--regular'
198
- autoHide={false}
199
- // TDOO(burdon): Is this the correct test?
200
- disabled={readonly || view.projection.fields.length <= 1}
201
- onClick={() => 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 || 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'>
221
- <FieldEditor
222
- readonly={readonly}
223
- projection={projection}
224
- field={field}
225
- registry={registry}
226
- onSave={handleClose}
227
- />
228
- </div>
229
- )}
230
- </List.Item>
231
- ))}
232
- </div>
233
- </>
234
- )}
235
- </List.Root>
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
+ )}
236
206
 
237
- {hiddenProperties.length > 0 && (
238
- <>
239
- <h2 className={mx(inputTextLabel)}>{t('hidden 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
+ />
240
218
 
241
- <List.Root<string>
242
- items={hiddenProperties}
243
- isItem={(item): item is string => typeof item === 'string'}
244
- getId={(property) => property}
245
- >
246
- {({ items: properties }) => (
247
- <div role='list' className='grid grid-cols-[1fr_min-content]'>
248
- {properties?.map((property) => (
249
- <List.Item<string>
250
- key={property}
251
- item={property}
252
- classNames={mx('grid grid-cols-subgrid col-span-2 rounded-sm', subtleHover)}
253
- >
254
- <List.ItemTitle>{property}</List.ItemTitle>
255
- <List.ItemButton
256
- label={t('show field label')}
257
- disabled={readonly}
258
- icon='ph--eye--regular'
259
- onClick={() => handleShow(property)}
260
- data-testid='show-field-button'
261
- />
262
- </List.Item>
263
- ))}
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))}
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
+ )}
281
+ </div>
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
+ })}
264
296
  </div>
265
- )}
266
- </List.Root>
267
- </>
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>
268
313
  )}
269
314
  </div>
315
+ );
316
+ },
317
+ );
270
318
 
271
- {!readonly && !expandedField && (
272
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
273
- <IconButton
274
- icon='ph--plus--regular'
275
- label={t('button add property')}
276
- onClick={readonly ? undefined : handleAdd}
277
- // TODO(burdon): Show field limit in ux (not tooltip).
278
- disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
279
- classNames='is-full'
280
- />
281
- </div>
282
- )}
283
- </div>
284
- );
285
- };
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
+ });
@@ -2,9 +2,8 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './deprecated';
6
-
7
5
  export * from './ControlSection';
8
6
  export * from './FieldEditor';
9
7
  export * from './Form';
8
+ export * from './ObjectPicker';
10
9
  export * from './ViewEditor';
@@ -9,16 +9,22 @@ import { Card } from '@dxos/react-ui-stack';
9
9
  import { JsonFilter } from '@dxos/react-ui-syntax-highlighter';
10
10
  import { mx, textBlockWidth } from '@dxos/react-ui-theme';
11
11
 
12
- export const TestLayout = ({ classNames, json, children }: ThemedClassName<PropsWithChildren<{ json?: any }>>) => (
13
- <div className='w-full h-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
14
- <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
15
- <JsonFilter data={json} classNames='w-full text-xs' testId='debug' />
16
- </div>
17
- );
12
+ type TestLayoutProps = ThemedClassName<PropsWithChildren<{ json?: any }>>;
18
13
 
19
- export const TestPanel = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
20
- <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>
21
- );
14
+ export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
15
+ return (
16
+ <div className='is-full bs-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
17
+ <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
18
+ <JsonFilter data={json} classNames='is-full text-xs' testId='debug' />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ type TestPanelProps = ThemedClassName<PropsWithChildren>;
24
+
25
+ export const TestPanel = ({ classNames, children }: TestPanelProps) => {
26
+ return <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>;
27
+ };
22
28
 
23
29
  // Symbol for accessing debug objects in tests.
24
30
  export const VIEW_EDITOR_DEBUG_SYMBOL = Symbol('viewEditorDebug');
@@ -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],