@dxos/react-ui-form 0.8.4-main.937b3ca → 0.8.4-main.9be5663bfe

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 (113) hide show
  1. package/README.md +0 -1
  2. package/dist/lib/browser/index.mjs +1003 -668
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +1003 -668
  6. package/dist/lib/node-esm/index.mjs.map +4 -4
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +3 -3
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +62 -61
  11. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/Form.d.ts +23 -18
  13. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.stories.d.ts +68 -67
  15. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/FormField.d.ts +7 -1
  17. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/FormFieldComponent.d.ts +0 -1
  19. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +2 -2
  21. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormFieldSet.d.ts +9 -10
  23. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/fields/RefField.d.ts +8 -6
  26. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +62 -61
  29. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  30. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
  31. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  32. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  33. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  34. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +10 -2
  35. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  36. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +62 -61
  37. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  39. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  40. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +83 -0
  41. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  42. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  43. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  44. package/dist/types/src/components/Settings/Settings.d.ts +35 -0
  45. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
  46. package/dist/types/src/components/Settings/Settings.stories.d.ts +82 -0
  47. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
  48. package/dist/types/src/components/Settings/index.d.ts +2 -0
  49. package/dist/types/src/components/Settings/index.d.ts.map +1 -0
  50. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +5 -5
  51. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  52. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +66 -64
  53. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/index.d.ts +3 -1
  55. package/dist/types/src/components/index.d.ts.map +1 -1
  56. package/dist/types/src/components/testing.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  58. package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
  59. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  60. package/dist/types/src/translations.d.ts +62 -61
  61. package/dist/types/src/translations.d.ts.map +1 -1
  62. package/dist/types/src/util/refs.d.ts +1 -0
  63. package/dist/types/src/util/refs.d.ts.map +1 -1
  64. package/dist/types/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +37 -38
  66. package/src/components/FieldEditor/FieldEditor.stories.tsx +7 -8
  67. package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
  68. package/src/components/FieldEditor/FieldEditor.tsx +3 -4
  69. package/src/components/Form/AUDIT.md +17 -0
  70. package/src/components/Form/Form.stories.tsx +25 -26
  71. package/src/components/Form/Form.tsx +101 -66
  72. package/src/components/Form/FormField.tsx +32 -14
  73. package/src/components/Form/FormFieldComponent.stories.tsx +5 -5
  74. package/src/components/Form/FormFieldComponent.tsx +17 -20
  75. package/src/components/Form/FormFieldSet.tsx +126 -105
  76. package/src/components/Form/fields/ArrayField.tsx +11 -8
  77. package/src/components/Form/fields/GeoPointField.tsx +6 -6
  78. package/src/components/Form/fields/MarkdownField.tsx +1 -1
  79. package/src/components/Form/fields/RefField.tsx +37 -20
  80. package/src/components/Form/fields/SelectField.tsx +1 -1
  81. package/src/components/Form/fields/SelectOptionField.stories.tsx +2 -3
  82. package/src/components/Form/fields/SelectOptionField.tsx +9 -10
  83. package/src/components/Form/fields/TupleField.tsx +1 -1
  84. package/src/components/ObjectForm/ObjectForm.tsx +113 -0
  85. package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
  86. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +13 -18
  87. package/src/components/ObjectPicker/ObjectPicker.tsx +71 -60
  88. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +63 -0
  89. package/src/components/ObjectProperties/ObjectProperties.tsx +129 -0
  90. package/src/components/ObjectProperties/index.ts +5 -0
  91. package/src/components/Settings/Settings.stories.tsx +55 -0
  92. package/src/components/Settings/Settings.tsx +129 -0
  93. package/src/components/Settings/index.ts +5 -0
  94. package/src/components/ViewEditor/ViewEditor.stories.tsx +36 -33
  95. package/src/components/ViewEditor/ViewEditor.test.tsx +3 -7
  96. package/src/components/ViewEditor/ViewEditor.tsx +117 -85
  97. package/src/components/index.ts +3 -1
  98. package/src/components/testing.tsx +20 -8
  99. package/src/hooks/useFormHandler.ts +42 -18
  100. package/src/hooks/useKeyHandler.tsx +7 -7
  101. package/src/translations.ts +63 -63
  102. package/src/util/refs.ts +7 -3
  103. package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
  104. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
  105. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -87
  106. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
  107. package/dist/types/src/components/ControlSection/index.d.ts +0 -2
  108. package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
  109. package/dist/types/src/components/Form/Form-old.stories.d.ts +0 -131
  110. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +0 -1
  111. package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
  112. package/src/components/ControlSection/ControlSection.tsx +0 -153
  113. package/src/components/Form/Form-old.stories.tsx +0 -314
@@ -9,19 +9,18 @@ import * as Option from 'effect/Option';
9
9
  import * as Schema from 'effect/Schema';
10
10
  import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from 'react';
11
11
 
12
- import { Filter, Format, Obj, Query, QueryAST, type SchemaRegistry } from '@dxos/echo';
12
+ import { Entity, Feed, Filter, Format, Obj, Query, QueryAST, Ref, type SchemaRegistry } from '@dxos/echo';
13
+ import { View } from '@dxos/echo';
13
14
  import { EchoSchema, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
14
15
  import { invariant } from '@dxos/invariant';
15
- import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
+ import { useObject, useQuery } from '@dxos/react-client/echo';
17
+ import { IconButton, Input, Message, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
18
  import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
17
19
  import { List } from '@dxos/react-ui-list';
18
20
  import {
19
- FieldSchema,
20
- type FieldType,
21
+ ParentLabelAnnotation,
21
22
  ProjectionModel,
22
23
  VIEW_FIELD_LIMIT,
23
- type View,
24
- createDirectChangeCallback,
25
24
  createEchoChangeCallback,
26
25
  getTypenameFromQuery,
27
26
  } from '@dxos/schema';
@@ -35,22 +34,20 @@ import {
35
34
  type FormFieldComponentProps,
36
35
  FormFieldLabel,
37
36
  type FormFieldMap,
37
+ type FormRootProps,
38
38
  } from '../Form';
39
39
 
40
- const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
41
- const listItemGrid = 'grid grid-cols-subgrid col-span-5';
42
-
43
40
  export type ViewEditorProps = ThemedClassName<
44
41
  {
45
42
  schema: Schema.Schema.AnyNoContext;
46
43
  view: View.View;
47
44
  mode?: 'schema' | 'tag';
48
45
  registry?: SchemaRegistry.SchemaRegistry;
49
- readonly?: boolean;
50
46
  showHeading?: boolean;
51
47
  onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
52
48
  onDelete?: (fieldId: string) => void;
53
- } & Pick<QueryFormProps, 'types' | 'tags'>
49
+ } & Pick<QueryFormProps, 'types' | 'tags'> &
50
+ Pick<FormRootProps<any>, 'readonly' | 'db'>
54
51
  >;
55
52
 
56
53
  /**
@@ -64,6 +61,7 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
64
61
  view,
65
62
  mode = 'schema',
66
63
  registry,
64
+ db,
67
65
  readonly,
68
66
  showHeading = false,
69
67
  types,
@@ -81,11 +79,9 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
81
79
  // Use reactive and mutable version of json schema when schema is mutable.
82
80
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
83
81
 
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);
82
+ // Always use createEchoChangeCallback since the view is ECHO-backed.
83
+ // Pass schema only when mutable to allow schema mutations.
84
+ const change = createEchoChangeCallback(view, schema instanceof EchoSchema ? schema : undefined);
89
85
 
90
86
  const model = new ProjectionModel({
91
87
  registry: atomRegistry,
@@ -98,18 +94,31 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
98
94
  }, [atomRegistry, schema, view]);
99
95
 
100
96
  useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
101
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
102
97
 
103
98
  const queueTarget = Match.value(view.query.ast).pipe(
104
- Match.when({ type: 'options' }, ({ options }) => {
105
- return Option.fromNullable(options.queues).pipe(
99
+ Match.when({ type: 'from' }, ({ from }) => {
100
+ if (from._tag !== 'scope') {
101
+ return undefined;
102
+ }
103
+ return Option.fromNullable(from.scope.queues).pipe(
106
104
  Option.flatMap((queues) => Array.head(queues)),
105
+ Option.map(String),
107
106
  Option.getOrUndefined,
108
107
  );
109
108
  }),
110
109
  Match.orElse(() => undefined),
111
110
  );
112
111
 
112
+ const feeds = useQuery(db, Filter.type(Feed.Feed));
113
+
114
+ const targetRef = useMemo(() => {
115
+ if (!queueTarget) {
116
+ return undefined;
117
+ }
118
+ const feed = feeds.find((feed) => Feed.getQueueDxn(feed)?.toString() === queueTarget);
119
+ return feed ? Ref.fromDXN(Entity.getDXN(feed)) : undefined;
120
+ }, [queueTarget, feeds]);
121
+
113
122
  const viewSchema = useMemo(() => {
114
123
  const base = Schema.Struct({
115
124
  query:
@@ -121,7 +130,12 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
121
130
  if (mode === 'tag') {
122
131
  return Schema.Struct({
123
132
  ...base.fields,
124
- target: Schema.optional(Schema.String.annotations({ title: 'Target Queue' })),
133
+ // TODO(wittjosiah): Replace Type.Feed with Dataset.Dataset when Ref.Ref supports unions.
134
+ target: Ref.Ref(Feed.Feed).pipe(
135
+ Schema.annotations({ title: 'Target Feed' }),
136
+ ParentLabelAnnotation.set(true),
137
+ Schema.optional,
138
+ ),
125
139
  }).pipe(Schema.mutable);
126
140
  }
127
141
 
@@ -133,9 +147,9 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
133
147
  const viewValues = useMemo(
134
148
  () => ({
135
149
  query: mode === 'schema' ? getTypenameFromQuery(view.query.ast) : view.query.ast,
136
- target: queueTarget,
150
+ target: targetRef,
137
151
  }),
138
- [mode, view.query.ast, queueTarget],
152
+ [mode, view.query.ast, targetRef],
139
153
  );
140
154
 
141
155
  const fieldMap = useMemo<FormFieldMap | undefined>(
@@ -143,49 +157,51 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
143
157
  [mode, types, tags],
144
158
  );
145
159
 
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
160
  const handleUpdate = useCallback(
154
161
  (values: any) => {
155
- requestAnimationFrame(() => {
156
- const query = mode === 'schema' ? Query.select(Filter.typename(values.query)).ast : values.query;
157
- onQueryChanged?.(query, values.target);
158
- });
162
+ const targetValue = values.target;
163
+ let queueDxn: string | undefined;
164
+
165
+ if (Ref.isRef(targetValue)) {
166
+ const feedDxn = targetValue.dxn.toString();
167
+ const feed = feeds.find((feed) => Obj.getDXN(feed).toString() === feedDxn);
168
+ if (feed) {
169
+ queueDxn = Feed.getQueueDxn(feed)?.toString();
170
+ }
171
+ }
172
+
173
+ // TODO(wittjosiah): Deep-clone the AST to plain JS or ECHO proxy arrays become objects with numeric keys.
174
+ const query =
175
+ mode === 'schema'
176
+ ? Query.select(Filter.typename(values.query)).ast
177
+ : JSON.parse(JSON.stringify(values.query));
178
+ onQueryChanged?.(query, queueDxn);
159
179
  },
160
- [onQueryChanged, view, queueTarget, mode],
180
+ [onQueryChanged, mode, feeds],
161
181
  );
162
182
 
163
183
  const handleDelete = useCallback(
164
184
  (fieldId: string) => {
165
185
  invariant(!readonly);
166
- if (fieldId === expandedField) {
167
- setExpandedField(undefined);
168
- }
169
-
170
186
  onDelete?.(fieldId);
171
187
  },
172
- [expandedField, onDelete, readonly],
188
+ [onDelete, readonly],
173
189
  );
174
190
 
175
191
  return (
176
192
  <div role='none' className={mx(classNames)}>
177
193
  {/* If readonly is set, then the callout is not needed. */}
178
194
  {schemaReadonly && !readonly && (
179
- <Callout.Root valence='info' classNames='mlb-cardSpacingBlock'>
180
- <Callout.Title>{t('system schema description')}</Callout.Title>
181
- </Callout.Root>
195
+ <Message.Root valence='info' classNames='my-form-padding'>
196
+ <Message.Title>{t('system-schema.description')}</Message.Title>
197
+ </Message.Root>
182
198
  )}
183
199
 
184
200
  {/* TODO(burdon): Is the form read-only or just the schema? */}
185
- <Form.Root schema={viewSchema} values={viewValues} fieldMap={fieldMap} autoSave onSave={handleUpdate}>
201
+ <Form.Root schema={viewSchema} values={viewValues} fieldMap={fieldMap} db={db} onValuesChanged={handleUpdate}>
186
202
  <Form.FieldSet />
187
203
 
188
- <FormFieldLabel label={t('fields label')} asChild />
204
+ <FormFieldLabel label={t('fields.label')} asChild />
189
205
  <FieldList
190
206
  schema={schema}
191
207
  view={view}
@@ -195,19 +211,6 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
195
211
  onDelete={handleDelete}
196
212
  />
197
213
  </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
214
  </div>
212
215
  );
213
216
  },
@@ -220,15 +223,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
220
223
  const schemaReadonly = !isMutable(schema);
221
224
  const { t } = useTranslation(translationKey);
222
225
 
226
+ // Subscribe to view changes for reactivity.
227
+ const [viewSnapshot] = useObject(view);
228
+
223
229
  const projectionModel = useMemo(() => {
224
230
  // Use reactive and mutable version of json schema when schema is mutable.
225
231
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
226
232
 
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);
233
+ // Always use createEchoChangeCallback since the view is ECHO-backed.
234
+ // Pass schema only when mutable to allow schema mutations.
235
+ const change = createEchoChangeCallback(view, schema instanceof EchoSchema ? schema : undefined);
232
236
 
233
237
  const model = new ProjectionModel({
234
238
  registry: atomRegistry,
@@ -240,10 +244,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
240
244
  return model;
241
245
  }, [atomRegistry, schema, view]);
242
246
 
243
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
247
+ const [expandedField, setExpandedField] = useState<View.FieldType['id']>();
248
+
249
+ const handleAdd = useCallback(() => {
250
+ invariant(!readonly);
251
+ const field = projectionModel.createFieldProjection();
252
+ setExpandedField(field.id);
253
+ }, [projectionModel, readonly]);
244
254
 
245
255
  const handleToggleField = useCallback(
246
- (field: FieldType) => {
256
+ (field: View.FieldType) => {
247
257
  setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
248
258
  },
249
259
  [readonly],
@@ -264,15 +274,15 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
264
274
  const handleMove = useCallback(
265
275
  (fromIndex: number, toIndex: number) => {
266
276
  invariant(!readonly);
267
- // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
268
- const fields = [...view.projection.fields];
269
- const [moved] = fields.splice(fromIndex, 1);
270
- fields.splice(toIndex, 0, moved);
271
- Obj.change(view, (v) => {
272
- v.projection.fields = fields;
277
+ Obj.change(view, (view) => {
278
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
279
+ const fields = [...view.projection.fields];
280
+ const [moved] = fields.splice(fromIndex, 1);
281
+ fields.splice(toIndex, 0, moved);
282
+ view.projection.fields = fields;
273
283
  });
274
284
  },
275
- [view.projection.fields, readonly],
285
+ [view, readonly],
276
286
  );
277
287
 
278
288
  const handleClose = useCallback(() => setExpandedField(undefined), []);
@@ -293,33 +303,44 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
293
303
  [projectionModel],
294
304
  );
295
305
 
306
+ if (!viewSnapshot) {
307
+ return null;
308
+ }
309
+
296
310
  return (
297
- <List.Root<FieldType>
298
- items={view.projection.fields as FieldType[]}
299
- isItem={Schema.is(FieldSchema)}
311
+ <List.Root<View.FieldType>
312
+ items={viewSnapshot.projection.fields as View.FieldType[]}
313
+ isItem={Schema.is(View.FieldSchema)}
300
314
  getId={(field) => field.id}
301
315
  onMove={readonly ? undefined : handleMove}
302
316
  readonly={readonly}
303
317
  >
304
318
  {({ items: fields }) => (
305
319
  <>
306
- {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
307
- <div role='list' className={listGrid}>
320
+ {showHeading && <h3 className='text-sm'>{t('field-path.label')}</h3>}
321
+ <div role='list' className='grid grid-cols-[min-content_1fr_min-content_min-content_min-content]'>
308
322
  {fields?.map((field) => {
309
323
  const hidden = field.visible === false;
310
324
  return (
311
- <List.Item<FieldType>
325
+ <List.Item<View.FieldType>
312
326
  key={field.id}
313
327
  item={field}
314
- classNames={listItemGrid}
328
+ classNames={'grid grid-cols-subgrid col-span-5'}
315
329
  aria-expanded={expandedField === field.id}
316
330
  >
317
- <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
331
+ <div
332
+ role='none'
333
+ className={mx(
334
+ subtleHover,
335
+ 'grid grid-cols-subgrid col-span-5',
336
+ 'rounded-xs cursor-pointer min-h-10',
337
+ )}
338
+ >
318
339
  <List.ItemDragHandle disabled={readonly || schemaReadonly} />
319
340
  <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
320
341
  {field.path}
321
342
  </List.ItemTitle>
322
- <List.ItemButton
343
+ <List.ItemIconButton
323
344
  label={t(hidden ? 'show field label' : 'hide field label')}
324
345
  data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
325
346
  icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
@@ -330,16 +351,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
330
351
  {!readonly && (
331
352
  <>
332
353
  <List.ItemDeleteButton
333
- label={t('delete field label')}
354
+ label={t('delete-field.label')}
334
355
  autoHide={false}
335
- disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
356
+ disabled={readonly || schemaReadonly || viewSnapshot.projection.fields.length <= 1}
336
357
  onClick={() => handleDelete(field.id)}
337
358
  data-testid='field.delete'
338
359
  />
339
- <IconButton
360
+ <List.ItemIconButton
340
361
  iconOnly
341
362
  variant='ghost'
342
- label={t('toggle expand label', { ns: osTranslations })}
363
+ label={t('toggle-expand.label', { ns: osTranslations })}
343
364
  icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
344
365
  onClick={() => handleToggleField(field)}
345
366
  data-testid='field.toggle'
@@ -348,7 +369,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
348
369
  )}
349
370
  </div>
350
371
  {expandedField === field.id && !readonly && (
351
- <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
372
+ <div role='none' className='col-span-full mt-1 mb-1 border border-separator rounded-md'>
352
373
  <FieldEditor
353
374
  readonly={readonly || schemaReadonly}
354
375
  registry={registry}
@@ -362,6 +383,17 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
362
383
  );
363
384
  })}
364
385
  </div>
386
+ {!readonly && !expandedField && (
387
+ <div role='none' className='my-form-padding'>
388
+ <IconButton
389
+ icon='ph--plus--regular'
390
+ label={t('add-property-button.label')}
391
+ onClick={handleAdd}
392
+ disabled={viewSnapshot.projection.fields.length >= VIEW_FIELD_LIMIT}
393
+ classNames='w-full'
394
+ />
395
+ </div>
396
+ )}
365
397
  </>
366
398
  )}
367
399
  </List.Root>
@@ -2,8 +2,10 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './ControlSection';
6
5
  export * from './FieldEditor';
7
6
  export * from './Form';
7
+ export * from './ObjectForm';
8
8
  export * from './ObjectPicker';
9
+ export * from './ObjectProperties';
10
+ export * from './Settings';
9
11
  export * from './ViewEditor';
@@ -4,19 +4,27 @@
4
4
 
5
5
  import React, { type PropsWithChildren } from 'react';
6
6
 
7
- import { type ThemedClassName } from '@dxos/react-ui';
8
- import { Card } from '@dxos/react-ui-mosaic';
9
- import { JsonFilter } from '@dxos/react-ui-syntax-highlighter';
10
- import { textBlockWidth } from '@dxos/ui-theme';
7
+ import { Column, type ThemedClassName } from '@dxos/react-ui';
8
+ import { Json } from '@dxos/react-ui-syntax-highlighter';
9
+ import { mx } from '@dxos/ui-theme';
11
10
 
12
11
  type TestLayoutProps = ThemedClassName<PropsWithChildren<{ json?: any }>>;
13
12
 
14
13
  export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
15
14
  return (
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>
15
+ <div className='h-full w-full grid grid-cols-[1fr_1fr] p-4 gap-4 overflow-hidden'>
16
+ <TestPanel classNames={['dx-container', classNames]}>
17
+ <Column.Root classNames='dx-container' gutter='sm'>
18
+ {children}
19
+ </Column.Root>
20
+ </TestPanel>
18
21
  <TestPanel classNames={'overflow-hidden'}>
19
- <JsonFilter testId='debug' data={json} classNames='bs-full text-xs' />
22
+ <Json.Root data={json}>
23
+ <Json.Content classNames='h-full'>
24
+ <Json.Filter />
25
+ <Json.Data testId='debug' classNames='text-xs' />
26
+ </Json.Content>
27
+ </Json.Root>
20
28
  </TestPanel>
21
29
  </div>
22
30
  );
@@ -25,7 +33,11 @@ export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
25
33
  type TestPanelProps = ThemedClassName<PropsWithChildren>;
26
34
 
27
35
  const TestPanel = ({ classNames, children }: TestPanelProps) => {
28
- return <Card.Root classNames={['bs-full overflow-y-auto', textBlockWidth, classNames]}>{children}</Card.Root>;
36
+ return (
37
+ <div role='none' className={mx(['dx-document h-full bg-modal-surface rounded-md', classNames])}>
38
+ {children}
39
+ </div>
40
+ );
29
41
  };
30
42
 
31
43
  // Symbol for accessing debug objects in tests.
@@ -8,13 +8,7 @@ import type * as SchemaAST from 'effect/SchemaAST';
8
8
  import { useCallback, useEffect, useMemo, useState } from 'react';
9
9
 
10
10
  import { type AnyProperties } from '@dxos/echo/internal';
11
- import {
12
- type JsonPath,
13
- createJsonPath,
14
- fromEffectValidationPath,
15
- getValue as getValue$,
16
- setValue as setValue$,
17
- } from '@dxos/effect';
11
+ import { type JsonPath, createJsonPath, fromEffectValidationPath, getValue as getValue$ } from '@dxos/effect';
18
12
  import { log } from '@dxos/log';
19
13
  import { useDefaultValue } from '@dxos/react-ui';
20
14
  import { type ValidationError, validateSchema } from '@dxos/schema';
@@ -136,11 +130,15 @@ export const useFormHandler = <T extends AnyProperties>({
136
130
  prop: valuesProp,
137
131
  defaultProp: defaultValues,
138
132
  onChange: () => {
139
- setValues(valuesProp ?? defaultValues);
140
- setChanged({});
141
- setTouched({});
142
- setErrors({});
143
- setSaving(false);
133
+ // Only reset when controlled: parent passed new values.
134
+ // Uncontrolled updates come from setValues in onValueChange.
135
+ if (valuesProp !== undefined) {
136
+ setValues(valuesProp ?? defaultValues);
137
+ setChanged({});
138
+ setTouched({});
139
+ setErrors({});
140
+ setSaving(false);
141
+ }
144
142
  },
145
143
  });
146
144
  // TODO(wittjosiah): Upgrade @radix-ui/react-use-controllable-state.
@@ -256,6 +254,7 @@ export const useFormHandler = <T extends AnyProperties>({
256
254
  log('onValueChange', { path, value });
257
255
 
258
256
  const jsonPath = createJsonPath(path);
257
+ const pathArray = path;
259
258
  let parsedValue = value as any;
260
259
  try {
261
260
  if (type._tag === 'NumberKeyword') {
@@ -266,21 +265,25 @@ export const useFormHandler = <T extends AnyProperties>({
266
265
  parsedValue = undefined;
267
266
  }
268
267
 
269
- // Update.
270
- const newValues = { ...setValue$(values, jsonPath, parsedValue) };
271
- setValues(newValues);
268
+ // Create merged values for validation and callback. Form never mutates; parent applies via onValuesChanged.
269
+ const newValues = mergeAtPath(values, pathArray as (string | number)[], parsedValue) as Partial<T>;
272
270
 
273
271
  // TODO(burdon): Check value has changed from original.
274
272
  const newChanged = { [jsonPath]: true };
275
- setChanged({ ...changed, ...newChanged });
273
+ setChanged((prev) => ({ ...prev, ...newChanged }));
276
274
 
277
275
  // Validate.
278
276
  const isValid = validate(newValues);
279
277
 
280
- // Callback.
278
+ // Notify parent; parent is responsible for applying the change.
281
279
  onValuesChanged?.(newValues, { isValid, changed: newChanged });
280
+
281
+ // For uncontrolled mode, update internal state. Controlled mode relies on parent to pass new values.
282
+ if (valuesProp === undefined) {
283
+ setValues(newValues);
284
+ }
282
285
  },
283
- [values, changed, validate, onValuesChanged],
286
+ [values, validate, onValuesChanged, valuesProp],
284
287
  );
285
288
 
286
289
  const onBlur = useCallback(
@@ -341,6 +344,27 @@ export const useFormHandler = <T extends AnyProperties>({
341
344
  );
342
345
  };
343
346
 
347
+ /**
348
+ * Creates a new object with value at path. Does not mutate source.
349
+ * Preserves arrays when path segments are numeric indices.
350
+ */
351
+ const mergeAtPath = (obj: any, path: readonly (string | number)[], value: any): any => {
352
+ if (path.length === 0) {
353
+ return value;
354
+ }
355
+
356
+ const [head, ...rest] = path;
357
+ const child = rest.length === 0 ? value : mergeAtPath(obj?.[head], rest, value);
358
+
359
+ if (Array.isArray(obj)) {
360
+ const copy = [...obj];
361
+ copy[head as number] = child;
362
+ return copy;
363
+ }
364
+
365
+ return { ...obj, [head]: child };
366
+ };
367
+
344
368
  const flatMap = (errors: ValidationError[]) => {
345
369
  return errors.reduce(
346
370
  (result, { path, message }) => {
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { useCallback, useEffect } from 'react';
5
+ import { type RefObject, useCallback, useLayoutEffect } from 'react';
6
6
 
7
7
  import { addEventListener } from '@dxos/async';
8
8
 
@@ -11,7 +11,7 @@ import { type FormHandler } from './useFormHandler';
11
11
  /**
12
12
  * Key handler.
13
13
  */
14
- export const useKeyHandler = (formElement: HTMLDivElement | null, form: FormHandler<any>) => {
14
+ export const useKeyHandler = (elRef: RefObject<HTMLDivElement | null>, form: FormHandler<any>) => {
15
15
  const handleKeyDown = useCallback(
16
16
  (event: KeyboardEvent) => {
17
17
  switch (event.key) {
@@ -26,12 +26,12 @@ export const useKeyHandler = (formElement: HTMLDivElement | null, form: FormHand
26
26
  [form.isValid, form.canSave, form.onSave, form.autoSave],
27
27
  );
28
28
 
29
- useEffect(() => {
30
- if (!formElement) {
29
+ useLayoutEffect(() => {
30
+ const el = elRef.current;
31
+ if (!el) {
31
32
  return;
32
33
  }
33
34
 
34
- // TODO(burdon): Move to @dxos/dom-util.
35
- return addEventListener(formElement, 'keydown', handleKeyDown);
36
- }, [formElement, handleKeyDown]);
35
+ return addEventListener(el, 'keydown', handleKeyDown);
36
+ }, [elRef, handleKeyDown]);
37
37
  };