@dxos/react-ui-form 0.8.4-main.21d9917 → 0.8.4-main.2244d791bb

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 (61) hide show
  1. package/dist/lib/browser/index.mjs +400 -357
  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 +400 -357
  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/Form/Form.d.ts +5 -5
  8. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  9. package/dist/types/src/components/Form/Form.stories.d.ts +1 -1
  10. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/FormFieldSet.d.ts +1 -1
  13. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/fields/RefField.d.ts +1 -1
  16. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  17. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  18. package/dist/types/src/components/Settings/Settings.d.ts +56 -0
  19. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
  20. package/dist/types/src/components/{ControlSection/ControlSection.stories.d.ts → Settings/Settings.stories.d.ts} +2 -8
  21. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
  22. package/dist/types/src/components/Settings/index.d.ts +2 -0
  23. package/dist/types/src/components/Settings/index.d.ts.map +1 -0
  24. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  25. package/dist/types/src/components/index.d.ts +1 -1
  26. package/dist/types/src/components/index.d.ts.map +1 -1
  27. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  28. package/dist/types/src/util/echo.d.ts +6 -0
  29. package/dist/types/src/util/echo.d.ts.map +1 -0
  30. package/dist/types/src/util/index.d.ts +1 -0
  31. package/dist/types/src/util/index.d.ts.map +1 -1
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +34 -34
  34. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  35. package/src/components/Form/Form-old.stories.tsx +1 -1
  36. package/src/components/Form/Form.stories.tsx +5 -2
  37. package/src/components/Form/Form.tsx +31 -21
  38. package/src/components/Form/FormFieldComponent.stories.tsx +1 -1
  39. package/src/components/Form/FormFieldComponent.tsx +15 -15
  40. package/src/components/Form/FormFieldSet.tsx +4 -2
  41. package/src/components/Form/fields/ArrayField.tsx +3 -0
  42. package/src/components/Form/fields/RefField.tsx +11 -3
  43. package/src/components/Form/fields/SelectOptionField.stories.tsx +1 -1
  44. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +1 -1
  45. package/src/components/Settings/Settings.stories.tsx +56 -0
  46. package/src/components/Settings/Settings.tsx +234 -0
  47. package/src/components/{ControlSection → Settings}/index.ts +1 -1
  48. package/src/components/ViewEditor/ViewEditor.stories.tsx +3 -3
  49. package/src/components/ViewEditor/ViewEditor.test.tsx +1 -5
  50. package/src/components/ViewEditor/ViewEditor.tsx +39 -44
  51. package/src/components/index.ts +1 -1
  52. package/src/hooks/useFormHandler.ts +7 -1
  53. package/src/util/echo.ts +25 -0
  54. package/src/util/index.ts +1 -0
  55. package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
  56. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
  57. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
  58. package/dist/types/src/components/ControlSection/index.d.ts +0 -2
  59. package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
  60. package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
  61. package/src/components/ControlSection/ControlSection.tsx +0 -153
@@ -0,0 +1,234 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { type PropsWithChildren } from 'react';
6
+
7
+ import {
8
+ Button,
9
+ type ButtonProps,
10
+ Input,
11
+ type Label,
12
+ ScrollArea,
13
+ type ThemedClassName,
14
+ toLocalizedString,
15
+ useTranslation,
16
+ } from '@dxos/react-ui';
17
+ import { mx } from '@dxos/ui-theme';
18
+
19
+ import { translationKey } from '../../translations';
20
+
21
+ const SETTINGS_ROOT_NAME = 'Settings.Root';
22
+ const SETTINGS_SECTION_NAME = 'Settings.Section';
23
+ const SETTINGS_SECTION_HEADING_NAME = 'Settings.SectionHeading';
24
+ const SETTINGS_GROUP_NAME = 'Settings.Group';
25
+ const SETTINGS_GROUP_BUTTON_NAME = 'Settings.GroupButton';
26
+ const SETTINGS_CONTAINER_NAME = 'Settings.Container';
27
+ const SETTINGS_FRAME_NAME = 'Settings.Frame';
28
+ const SETTINGS_FRAME_ITEM_NAME = 'Settings.FrameItem';
29
+ const SETTINGS_ITEM_NAME = 'Settings.Item';
30
+ const SETTINGS_ITEM_INPUT_NAME = 'Settings.ItemInput';
31
+
32
+ const styles = {
33
+ title: 'pbe-trimMd text-baseText text-lg',
34
+ description: 'text-base text-description',
35
+ grid: 'grid grid-cols-1 md:grid-cols-[3fr_2fr] gap-x-trimLg',
36
+ };
37
+
38
+ //
39
+ // Root
40
+ //
41
+
42
+ type SettingsRootProps = PropsWithChildren;
43
+
44
+ const SettingsRoot = ({ children }: SettingsRootProps) => {
45
+ return (
46
+ <ScrollArea.Root orientation='vertical'>
47
+ <ScrollArea.Viewport classNames='p-trimMd'>{children}</ScrollArea.Viewport>
48
+ </ScrollArea.Root>
49
+ );
50
+ };
51
+
52
+ SettingsRoot.displayName = SETTINGS_ROOT_NAME;
53
+
54
+ //
55
+ // Section
56
+ //
57
+
58
+ type SettingsSectionProps = PropsWithChildren<{
59
+ title: Label;
60
+ description?: Label;
61
+ }>;
62
+
63
+ const SettingsSection = ({ title, description, children }: SettingsSectionProps) => {
64
+ return (
65
+ <>
66
+ <SettingsSectionHeading title={title} description={description} />
67
+ <div className='is-full pbs-trimMd space-y-trimMd'>{children}</div>
68
+ </>
69
+ );
70
+ };
71
+
72
+ SettingsSection.displayName = SETTINGS_SECTION_NAME;
73
+
74
+ //
75
+ // Section Heading
76
+ //
77
+
78
+ const SettingsSectionHeading = ({ title, description }: Omit<SettingsSectionProps, 'children'>) => {
79
+ const { t } = useTranslation(translationKey);
80
+ return (
81
+ <>
82
+ <h2 className='pli-trimMd container-max-width text-xl mbs-trimMd mbe-trimMd'>{toLocalizedString(title, t)}</h2>
83
+ {description && (
84
+ <p className='pli-trimMd mlb-trimMd container-max-width text-description'>
85
+ {toLocalizedString(description, t)}
86
+ </p>
87
+ )}
88
+ </>
89
+ );
90
+ };
91
+
92
+ SettingsSectionHeading.displayName = SETTINGS_SECTION_HEADING_NAME;
93
+
94
+ //
95
+ // Group Button
96
+ //
97
+
98
+ const SettingsGroupButton = ({ classNames, ...props }: ButtonProps) => {
99
+ return <Button {...props} classNames={['md:col-span-2', classNames]} />;
100
+ };
101
+
102
+ SettingsGroupButton.displayName = SETTINGS_GROUP_BUTTON_NAME;
103
+
104
+ //
105
+ // Group
106
+ //
107
+
108
+ type SettingsGroupProps = ThemedClassName<PropsWithChildren>;
109
+
110
+ const SettingsGroup = ({ children, classNames }: SettingsGroupProps) => (
111
+ <div role='none' className={mx('group container-max-width space-y-trimMd', classNames)}>
112
+ {children}
113
+ </div>
114
+ );
115
+
116
+ SettingsGroup.displayName = SETTINGS_GROUP_NAME;
117
+
118
+ //
119
+ // Frame
120
+ //
121
+
122
+ const SettingsFrame = ({ children }: SettingsGroupProps) => (
123
+ <div role='none' className={mx('container-max-width p-trimMd', 'border border-separator rounded-md')}>
124
+ {children}
125
+ </div>
126
+ );
127
+
128
+ SettingsFrame.displayName = SETTINGS_FRAME_NAME;
129
+
130
+ //
131
+ // Frame Item
132
+ //
133
+
134
+ const SettingsFrameItem = ({ title, description, children }: SettingsItemProps) => {
135
+ const { t } = useTranslation(translationKey);
136
+
137
+ return (
138
+ <div role='group' className='min-is-0'>
139
+ <h3 className='text-lg mbe-2'>{toLocalizedString(title, t)}</h3>
140
+ {description && <p className='mlb-trimSm md:mbe-0 text-description'>{toLocalizedString(description, t)}</p>}
141
+ {children}
142
+ </div>
143
+ );
144
+ };
145
+
146
+ SettingsFrameItem.displayName = SETTINGS_FRAME_ITEM_NAME;
147
+
148
+ //
149
+ // Container
150
+ //
151
+
152
+ const SettingsContainer = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => {
153
+ return (
154
+ <div
155
+ role='none'
156
+ className={mx([
157
+ 'container-max-width',
158
+ '*:first:!mbs-0 *:last:!mbe-0 pli-trimMd plb-trimMd',
159
+ 'border border-separator rounded-md',
160
+ classNames,
161
+ ])}
162
+ >
163
+ {children}
164
+ </div>
165
+ );
166
+ };
167
+
168
+ SettingsContainer.displayName = SETTINGS_CONTAINER_NAME;
169
+
170
+ //
171
+ // Item
172
+ //
173
+
174
+ type SettingsItemProps = PropsWithChildren<{
175
+ title: Label;
176
+ description?: Label;
177
+ }>;
178
+
179
+ const SettingsItem = ({ title, description = '', children }: SettingsItemProps) => {
180
+ const { t } = useTranslation(translationKey);
181
+
182
+ return (
183
+ <SettingsContainer classNames={styles.grid}>
184
+ <h3 className={mx(styles.title, 'md:col-span-2')}>{toLocalizedString(title, t)}</h3>
185
+ <p className={styles.description}>{toLocalizedString(description, t)}</p>
186
+ <div role='none' className='text-end plb-1'>
187
+ {children}
188
+ </div>
189
+ </SettingsContainer>
190
+ );
191
+ };
192
+
193
+ SettingsItem.displayName = SETTINGS_ITEM_NAME;
194
+
195
+ //
196
+ // Item Input
197
+ //
198
+
199
+ const SettingsItemInput = ({ title, description = '', children }: SettingsItemProps) => {
200
+ const { t } = useTranslation(translationKey);
201
+
202
+ return (
203
+ <Input.Root>
204
+ <SettingsContainer classNames={styles.grid}>
205
+ <Input.Label classNames={mx(styles.title, 'md:col-span-2')}>{toLocalizedString(title, t)}</Input.Label>
206
+ <Input.DescriptionAndValidation>
207
+ <Input.Description classNames={styles.description}>{toLocalizedString(description, t)}</Input.Description>
208
+ </Input.DescriptionAndValidation>
209
+ <div role='none' className='text-end plb-1'>
210
+ {children}
211
+ </div>
212
+ </SettingsContainer>
213
+ </Input.Root>
214
+ );
215
+ };
216
+
217
+ SettingsItemInput.displayName = SETTINGS_ITEM_INPUT_NAME;
218
+
219
+ //
220
+ // Settings
221
+ //
222
+
223
+ export const Settings = {
224
+ Root: SettingsRoot,
225
+ Section: SettingsSection,
226
+ SectionHeading: SettingsSectionHeading,
227
+ Group: SettingsGroup,
228
+ GroupButton: SettingsGroupButton,
229
+ Container: SettingsContainer,
230
+ Frame: SettingsFrame,
231
+ FrameItem: SettingsFrameItem,
232
+ Item: SettingsItem,
233
+ ItemInput: SettingsItemInput,
234
+ };
@@ -2,4 +2,4 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './ControlSection';
5
+ export * from './Settings';
@@ -13,7 +13,7 @@ import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
13
13
  import { useAsyncEffect } from '@dxos/react-ui';
14
14
  import { withTheme } from '@dxos/react-ui/testing';
15
15
  import { type ProjectionModel, View, getTypenameFromQuery } from '@dxos/schema';
16
- import { Employer, Organization, Person, Project } from '@dxos/types';
16
+ import { Employer, Organization, Person, Pipeline } from '@dxos/types';
17
17
 
18
18
  import { translations } from '../../translations';
19
19
  import { TestLayout, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
@@ -24,7 +24,7 @@ const types = [
24
24
  // TODO(burdon): Get label from annotation.
25
25
  { value: Organization.Organization.typename, label: 'Organization' },
26
26
  { value: Person.Person.typename, label: 'Person' },
27
- { value: Project.Project.typename, label: 'Project' },
27
+ { value: Pipeline.Pipeline.typename, label: 'Project' },
28
28
  { value: Employer.Employer.typename, label: 'Employer' },
29
29
  ];
30
30
 
@@ -175,7 +175,7 @@ const meta = {
175
175
  space.db.add(Tag.make({ label: 'New' }));
176
176
  },
177
177
  }),
178
- withTheme,
178
+ withTheme(),
179
179
  ],
180
180
  parameters: {
181
181
  layout: 'fullscreen',
@@ -89,14 +89,10 @@ describe('ViewEditor', () => {
89
89
  await Default.run();
90
90
  await waitForViewEditor();
91
91
 
92
- // Click the add property button.
92
+ // Click the add property button. The new field is auto-expanded by handleAdd.
93
93
  const addButton = screen.getByText('Add property');
94
94
  fireEvent.click(addButton);
95
95
 
96
- // Open the last field.
97
- const buttons = screen.getAllByTestId('field.toggle');
98
- fireEvent.click(buttons.at(-1)!);
99
-
100
96
  // Fill out the property field.
101
97
  const fieldInput = screen.getByPlaceholderText('Property name');
102
98
  fireEvent.change(fieldInput, { target: { value: 'added_property' } });
@@ -12,6 +12,7 @@ import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMem
12
12
  import { Filter, Format, Obj, Query, QueryAST, type SchemaRegistry } from '@dxos/echo';
13
13
  import { EchoSchema, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
14
14
  import { invariant } from '@dxos/invariant';
15
+ import { useObject } from '@dxos/react-client/echo';
15
16
  import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
17
  import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
17
18
  import { List } from '@dxos/react-ui-list';
@@ -21,7 +22,6 @@ import {
21
22
  ProjectionModel,
22
23
  VIEW_FIELD_LIMIT,
23
24
  type View,
24
- createDirectChangeCallback,
25
25
  createEchoChangeCallback,
26
26
  getTypenameFromQuery,
27
27
  } from '@dxos/schema';
@@ -81,11 +81,9 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
81
81
  // Use reactive and mutable version of json schema when schema is mutable.
82
82
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
83
83
 
84
- // Use createEchoChangeCallback for mutable schemas (EchoSchema), otherwise use direct mutation.
85
- const change =
86
- schema instanceof EchoSchema
87
- ? createEchoChangeCallback(view, schema)
88
- : createDirectChangeCallback(view.projection, jsonSchema);
84
+ // Always use createEchoChangeCallback since the view is ECHO-backed.
85
+ // Pass schema only when mutable to allow schema mutations.
86
+ const change = createEchoChangeCallback(view, schema instanceof EchoSchema ? schema : undefined);
89
87
 
90
88
  const model = new ProjectionModel({
91
89
  registry: atomRegistry,
@@ -98,7 +96,6 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
98
96
  }, [atomRegistry, schema, view]);
99
97
 
100
98
  useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
101
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
102
99
 
103
100
  const queueTarget = Match.value(view.query.ast).pipe(
104
101
  Match.when({ type: 'options' }, ({ options }) => {
@@ -143,13 +140,6 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
143
140
  [mode, types, tags],
144
141
  );
145
142
 
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
143
  const handleUpdate = useCallback(
154
144
  (values: any) => {
155
145
  requestAnimationFrame(() => {
@@ -163,13 +153,9 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
163
153
  const handleDelete = useCallback(
164
154
  (fieldId: string) => {
165
155
  invariant(!readonly);
166
- if (fieldId === expandedField) {
167
- setExpandedField(undefined);
168
- }
169
-
170
156
  onDelete?.(fieldId);
171
157
  },
172
- [expandedField, onDelete, readonly],
158
+ [onDelete, readonly],
173
159
  );
174
160
 
175
161
  return (
@@ -195,19 +181,6 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
195
181
  onDelete={handleDelete}
196
182
  />
197
183
  </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
184
  </div>
212
185
  );
213
186
  },
@@ -220,15 +193,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
220
193
  const schemaReadonly = !isMutable(schema);
221
194
  const { t } = useTranslation(translationKey);
222
195
 
196
+ // Subscribe to view changes for reactivity.
197
+ const [viewSnapshot] = useObject(view);
198
+
223
199
  const projectionModel = useMemo(() => {
224
200
  // Use reactive and mutable version of json schema when schema is mutable.
225
201
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
226
202
 
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);
203
+ // Always use createEchoChangeCallback since the view is ECHO-backed.
204
+ // Pass schema only when mutable to allow schema mutations.
205
+ const change = createEchoChangeCallback(view, schema instanceof EchoSchema ? schema : undefined);
232
206
 
233
207
  const model = new ProjectionModel({
234
208
  registry: atomRegistry,
@@ -242,6 +216,12 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
242
216
 
243
217
  const [expandedField, setExpandedField] = useState<FieldType['id']>();
244
218
 
219
+ const handleAdd = useCallback(() => {
220
+ invariant(!readonly);
221
+ const field = projectionModel.createFieldProjection();
222
+ setExpandedField(field.id);
223
+ }, [projectionModel, readonly]);
224
+
245
225
  const handleToggleField = useCallback(
246
226
  (field: FieldType) => {
247
227
  setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
@@ -264,15 +244,15 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
264
244
  const handleMove = useCallback(
265
245
  (fromIndex: number, toIndex: number) => {
266
246
  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
247
  Obj.change(view, (v) => {
248
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
249
+ const fields = [...v.projection.fields];
250
+ const [moved] = fields.splice(fromIndex, 1);
251
+ fields.splice(toIndex, 0, moved);
272
252
  v.projection.fields = fields;
273
253
  });
274
254
  },
275
- [view.projection.fields, readonly],
255
+ [view, readonly],
276
256
  );
277
257
 
278
258
  const handleClose = useCallback(() => setExpandedField(undefined), []);
@@ -293,9 +273,13 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
293
273
  [projectionModel],
294
274
  );
295
275
 
276
+ if (!viewSnapshot) {
277
+ return null;
278
+ }
279
+
296
280
  return (
297
281
  <List.Root<FieldType>
298
- items={view.projection.fields as FieldType[]}
282
+ items={viewSnapshot.projection.fields as FieldType[]}
299
283
  isItem={Schema.is(FieldSchema)}
300
284
  getId={(field) => field.id}
301
285
  onMove={readonly ? undefined : handleMove}
@@ -332,7 +316,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
332
316
  <List.ItemDeleteButton
333
317
  label={t('delete field label')}
334
318
  autoHide={false}
335
- disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
319
+ disabled={readonly || schemaReadonly || viewSnapshot.projection.fields.length <= 1}
336
320
  onClick={() => handleDelete(field.id)}
337
321
  data-testid='field.delete'
338
322
  />
@@ -362,6 +346,17 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
362
346
  );
363
347
  })}
364
348
  </div>
349
+ {!readonly && !expandedField && (
350
+ <div role='none' className='mlb-cardSpacingBlock'>
351
+ <IconButton
352
+ icon='ph--plus--regular'
353
+ label={t('add property button label')}
354
+ onClick={handleAdd}
355
+ disabled={viewSnapshot.projection.fields.length >= VIEW_FIELD_LIMIT}
356
+ classNames='is-full'
357
+ />
358
+ </div>
359
+ )}
365
360
  </>
366
361
  )}
367
362
  </List.Root>
@@ -2,8 +2,8 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './ControlSection';
6
5
  export * from './FieldEditor';
7
6
  export * from './Form';
8
7
  export * from './ObjectPicker';
8
+ export * from './Settings';
9
9
  export * from './ViewEditor';
@@ -7,6 +7,7 @@ import type * as Schema from 'effect/Schema';
7
7
  import type * as SchemaAST from 'effect/SchemaAST';
8
8
  import { useCallback, useEffect, useMemo, useState } from 'react';
9
9
 
10
+ import { Obj } from '@dxos/echo';
10
11
  import { type AnyProperties } from '@dxos/echo/internal';
11
12
  import {
12
13
  type JsonPath,
@@ -20,6 +21,8 @@ import { useDefaultValue } from '@dxos/react-ui';
20
21
  import { type ValidationError, validateSchema } from '@dxos/schema';
21
22
  import { type MaybePromise } from '@dxos/util';
22
23
 
24
+ import { setValueEchoAware } from '../util';
25
+
23
26
  export type FormFieldStatus = {
24
27
  status?: 'error';
25
28
  error?: string;
@@ -267,7 +270,10 @@ export const useFormHandler = <T extends AnyProperties>({
267
270
  }
268
271
 
269
272
  // Update.
270
- const newValues = { ...setValue$(values, jsonPath, parsedValue) };
273
+ // For ECHO objects, mutate in place via Obj.change(); for plain objects, spread to create new reference.
274
+ const newValues = Obj.isObject(values)
275
+ ? (setValueEchoAware(values, jsonPath, parsedValue), values)
276
+ : { ...setValue$(values, jsonPath, parsedValue) };
271
277
  setValues(newValues);
272
278
 
273
279
  // TODO(burdon): Check value has changed from original.
@@ -0,0 +1,25 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Obj } from '@dxos/echo';
6
+ import { type JsonPath, setValue as setValuePlain, splitJsonPath } from '@dxos/effect';
7
+
8
+ /**
9
+ * Sets a value on an object, using Obj.change() if it's an ECHO object.
10
+ */
11
+ // TODO(wittjosiah): Consider refactoring Form to not know about ECHO at all.
12
+ // Instead of directly mutating the values object, Form could work with a plain
13
+ // object copy and communicate all mutations through callbacks (e.g., onValuesChanged).
14
+ // The caller would then be responsible for applying changes to ECHO objects via
15
+ // Obj.change(). This would keep Form agnostic to the underlying data layer and
16
+ // avoid coupling react-ui-form to @dxos/echo.
17
+ export const setValueEchoAware = <T extends object>(obj: T, path: JsonPath, value: any): T => {
18
+ if (Obj.isObject(obj)) {
19
+ Obj.change(obj, (mutableObj) => {
20
+ Obj.setValue(mutableObj, splitJsonPath(path), value);
21
+ });
22
+ return obj;
23
+ }
24
+ return setValuePlain(obj, path, value);
25
+ };
package/src/util/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './echo';
5
6
  export * from './field';
6
7
  export * from './formatting';
7
8
  export * from './properties';
@@ -1,23 +0,0 @@
1
- import React, { type ComponentPropsWithoutRef, type PropsWithChildren } from 'react';
2
- import { type ButtonProps, type Label, type ThemedClassName } from '@dxos/react-ui';
3
- export type ControlPageParams = ThemedClassName<ComponentPropsWithoutRef<'div'>>;
4
- export declare const ControlPage: ({ children, classNames, ...props }: ControlPageParams) => React.JSX.Element;
5
- export type ControlSectionProps = PropsWithChildren<{
6
- title: Label;
7
- description?: Label;
8
- }>;
9
- export declare const ControlSection: ({ title, description, children }: ControlSectionProps) => React.JSX.Element;
10
- export declare const ControlSectionHeading: ({ title, description }: Omit<ControlSectionProps, "children">) => React.JSX.Element;
11
- export declare const ControlGroupButton: ({ classNames, ...props }: ButtonProps) => React.JSX.Element;
12
- export type ControlGroupProps = ThemedClassName<PropsWithChildren>;
13
- export declare const ControlGroup: ({ children, classNames }: ControlGroupProps) => React.JSX.Element;
14
- export declare const ControlFrame: ({ children }: ControlGroupProps) => React.JSX.Element;
15
- export declare const controlItemClasses: string;
16
- export type ControlItemProps = PropsWithChildren<{
17
- title: Label;
18
- description?: Label;
19
- }>;
20
- export declare const ControlItem: ({ title, description, children }: ControlItemProps) => React.JSX.Element;
21
- export declare const ControlItemInput: ({ title, description, children }: ControlItemProps) => React.JSX.Element;
22
- export declare const ControlFrameItem: ({ title, description, children }: ControlItemProps) => React.JSX.Element;
23
- //# sourceMappingURL=ControlSection.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ControlSection.d.ts","sourceRoot":"","sources":["../../../../../src/components/ControlSection/ControlSection.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,EAAE,KAAK,wBAAwB,EAAE,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAErF,OAAO,EAEL,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,eAAe,EAGrB,MAAM,gBAAgB,CAAC;AAMxB,MAAM,MAAM,iBAAiB,GAAG,eAAe,CAAC,wBAAwB,CAAC,KAAK,CAAC,CAAC,CAAC;AAEjF,eAAO,MAAM,WAAW,GAAI,oCAAoC,iBAAiB,sBAQhF,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,CAAC;IAClD,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,CAAC,EAAE,KAAK,CAAC;CACrB,CAAC,CAAC;AAEH,eAAO,MAAM,cAAc,GAAI,kCAAkC,mBAAmB,sBAOnF,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAI,wBAAwB,IAAI,CAAC,mBAAmB,EAAE,UAAU,CAAC,sBAYlG,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,0BAA0B,WAAW,sBAEvE,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,eAAe,CAAC,iBAAiB,CAAC,CAAC;AAEnE,eAAO,MAAM,YAAY,GAAI,0BAA0B,iBAAiB,sBAWvE,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,cAAc,iBAAiB,sBAU3D,CAAC;AAEF,eAAO,MAAM,kBAAkB,QAK7B,CAAC;AAKH,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,CAAC;IAC/C,KAAK,EAAE,KAAK,CAAC;IACb,WAAW,CAAC,EAAE,KAAK,CAAC;CACrB,CAAC,CAAC;AAEH,eAAO,MAAM,WAAW,GAAI,kCAAkC,gBAAgB,sBAY7E,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,kCAAkC,gBAAgB,sBAoBlF,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,kCAAkC,gBAAgB,sBAUlF,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ControlSection.stories.d.ts","sourceRoot":"","sources":["../../../../../src/components/ControlSection/ControlSection.stories.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,KAAK,MAAM,OAAO,CAAC;AAQ1B,QAAA,MAAM,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAa6B,CAAC;AAExC,eAAe,IAAI,CAAC;AAEpB,KAAK,KAAK,GAAG,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;AAEnC,eAAO,MAAM,OAAO,EAAE,KAKrB,CAAC"}
@@ -1,2 +0,0 @@
1
- export * from './ControlSection';
2
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ControlSection/index.ts"],"names":[],"mappings":"AAIA,cAAc,kBAAkB,CAAC"}
@@ -1,38 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React from 'react';
7
-
8
- import { withTheme } from '@dxos/react-ui/testing';
9
-
10
- import { translations } from '../../translations';
11
-
12
- import { ControlPage, ControlSection } from './ControlSection';
13
-
14
- const meta = {
15
- title: 'ui/react-ui-form/ControlSection',
16
- component: ControlSection,
17
- render: (args) => (
18
- <ControlPage classNames='is-[40rem]'>
19
- <ControlSection {...args} />
20
- </ControlPage>
21
- ),
22
- decorators: [withTheme],
23
- parameters: {
24
- layout: 'centered',
25
- translations,
26
- },
27
- } satisfies Meta<typeof ControlSection>;
28
-
29
- export default meta;
30
-
31
- type Story = StoryObj<typeof meta>;
32
-
33
- export const Default: Story = {
34
- args: {
35
- title: 'Control Section',
36
- description: 'This is a control section',
37
- },
38
- };