@dxos/react-ui-form 0.8.4-main.bc674ce → 0.8.4-main.bcb3aa67d6

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 (99) hide show
  1. package/README.md +0 -1
  2. package/dist/lib/browser/index.mjs +620 -583
  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 +620 -583
  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 +61 -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 +18 -15
  13. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.stories.d.ts +67 -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 +7 -5
  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 +61 -61
  29. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +10 -2
  30. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  31. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +61 -61
  32. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Settings/Settings.d.ts +39 -0
  34. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
  35. package/dist/types/src/components/Settings/Settings.stories.d.ts +81 -0
  36. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Settings/index.d.ts +2 -0
  38. package/dist/types/src/components/Settings/index.d.ts.map +1 -0
  39. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +5 -5
  40. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  41. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +65 -64
  42. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  43. package/dist/types/src/components/index.d.ts +1 -1
  44. package/dist/types/src/components/index.d.ts.map +1 -1
  45. package/dist/types/src/components/testing.d.ts.map +1 -1
  46. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  47. package/dist/types/src/translations.d.ts +61 -61
  48. package/dist/types/src/translations.d.ts.map +1 -1
  49. package/dist/types/src/util/index.d.ts +0 -1
  50. package/dist/types/src/util/index.d.ts.map +1 -1
  51. package/dist/types/src/util/refs.d.ts +1 -0
  52. package/dist/types/src/util/refs.d.ts.map +1 -1
  53. package/dist/types/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +36 -38
  55. package/src/components/FieldEditor/FieldEditor.stories.tsx +4 -6
  56. package/src/components/FieldEditor/FieldEditor.tsx +3 -4
  57. package/src/components/Form/AUDIT.md +17 -0
  58. package/src/components/Form/Form.stories.tsx +23 -24
  59. package/src/components/Form/Form.tsx +58 -65
  60. package/src/components/Form/FormField.tsx +27 -8
  61. package/src/components/Form/FormFieldComponent.stories.tsx +5 -5
  62. package/src/components/Form/FormFieldComponent.tsx +17 -20
  63. package/src/components/Form/FormFieldSet.tsx +108 -104
  64. package/src/components/Form/fields/ArrayField.tsx +11 -8
  65. package/src/components/Form/fields/GeoPointField.tsx +6 -6
  66. package/src/components/Form/fields/MarkdownField.tsx +1 -1
  67. package/src/components/Form/fields/RefField.tsx +35 -17
  68. package/src/components/Form/fields/SelectField.tsx +1 -1
  69. package/src/components/Form/fields/SelectOptionField.stories.tsx +2 -2
  70. package/src/components/Form/fields/SelectOptionField.tsx +9 -10
  71. package/src/components/Form/fields/TupleField.tsx +1 -1
  72. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +10 -14
  73. package/src/components/ObjectPicker/ObjectPicker.tsx +71 -60
  74. package/src/components/Settings/Settings.stories.tsx +56 -0
  75. package/src/components/Settings/Settings.tsx +129 -0
  76. package/src/components/{ControlSection → Settings}/index.ts +1 -1
  77. package/src/components/ViewEditor/ViewEditor.stories.tsx +22 -20
  78. package/src/components/ViewEditor/ViewEditor.test.tsx +3 -6
  79. package/src/components/ViewEditor/ViewEditor.tsx +99 -70
  80. package/src/components/index.ts +1 -1
  81. package/src/components/testing.tsx +20 -8
  82. package/src/hooks/useFormHandler.ts +42 -24
  83. package/src/translations.ts +62 -63
  84. package/src/util/index.ts +0 -1
  85. package/src/util/refs.ts +7 -3
  86. package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
  87. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
  88. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -87
  89. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
  90. package/dist/types/src/components/ControlSection/index.d.ts +0 -2
  91. package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
  92. package/dist/types/src/components/Form/Form-old.stories.d.ts +0 -131
  93. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +0 -1
  94. package/dist/types/src/util/echo.d.ts +0 -6
  95. package/dist/types/src/util/echo.d.ts.map +0 -1
  96. package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
  97. package/src/components/ControlSection/ControlSection.tsx +0 -153
  98. package/src/components/Form/Form-old.stories.tsx +0 -314
  99. package/src/util/echo.ts +0 -25
@@ -6,14 +6,15 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
- import { DXN, Filter, Obj, Query, type QueryAST, Tag, Type } from '@dxos/echo';
9
+ import { DXN, Filter, JsonSchema, Obj, Query, type QueryAST, Tag, Type } from '@dxos/echo';
10
+ import { type View } from '@dxos/echo';
10
11
  import { type EchoSchema, Format, type Mutable } from '@dxos/echo/internal';
11
12
  import { useQuery } from '@dxos/react-client/echo';
12
13
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
13
14
  import { useAsyncEffect } from '@dxos/react-ui';
14
- import { withTheme } from '@dxos/react-ui/testing';
15
- import { type ProjectionModel, View, getTypenameFromQuery } from '@dxos/schema';
16
- import { Employer, Organization, Person, Project } from '@dxos/types';
15
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
16
+ import { type ProjectionModel, ViewModel, getTypenameFromQuery } from '@dxos/schema';
17
+ import { Employer, Organization, Person, Pipeline } from '@dxos/types';
17
18
 
18
19
  import { translations } from '../../translations';
19
20
  import { TestLayout, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
@@ -24,7 +25,7 @@ const types = [
24
25
  // TODO(burdon): Get label from annotation.
25
26
  { value: Organization.Organization.typename, label: 'Organization' },
26
27
  { value: Person.Person.typename, label: 'Person' },
27
- { value: Project.Project.typename, label: 'Project' },
28
+ { value: Pipeline.Pipeline.typename, label: 'Project' },
28
29
  { value: Employer.Employer.typename, label: 'Employer' },
29
30
  ];
30
31
 
@@ -35,9 +36,9 @@ export type ViewEditorDebugObjects = {
35
36
  projection: ProjectionModel;
36
37
  };
37
38
 
38
- type StoryProps = Pick<ViewEditorProps, 'readonly' | 'mode'>;
39
+ type DefaultStoryProps = Pick<ViewEditorProps, 'readonly' | 'mode'>;
39
40
 
40
- const DefaultStory = (props: StoryProps) => {
41
+ const DefaultStory = (props: DefaultStoryProps) => {
41
42
  const { space } = useClientStory();
42
43
  const [schema, setSchema] = useState<EchoSchema>();
43
44
  const [view, setView] = useState<View.View>();
@@ -53,7 +54,7 @@ const DefaultStory = (props: StoryProps) => {
53
54
  salary: Format.Currency(),
54
55
  }).pipe(
55
56
  Type.object({
56
- typename: 'example.com/type/Test',
57
+ typename: 'com.example.type.test',
57
58
  version: '0.1.0',
58
59
  }),
59
60
  );
@@ -64,16 +65,16 @@ const DefaultStory = (props: StoryProps) => {
64
65
  completed: Schema.Boolean,
65
66
  }).pipe(
66
67
  Type.object({
67
- typename: 'example.com/type/Alternate',
68
+ typename: 'com.example.type.alternate',
68
69
  version: '0.1.0',
69
70
  }),
70
71
  );
71
72
 
72
73
  const [testSchema] = await space.db.schemaRegistry.register([TestSchema, AlternateSchema]);
73
- const view = View.make({
74
+ const view = ViewModel.make({
74
75
  name: 'Test',
75
76
  query: Query.select(Filter.type(TestSchema)),
76
- jsonSchema: Type.toJsonSchema(TestSchema),
77
+ jsonSchema: JsonSchema.toJsonSchema(TestSchema),
77
78
  });
78
79
 
79
80
  setSchema(testSchema);
@@ -89,9 +90,9 @@ const DefaultStory = (props: StoryProps) => {
89
90
 
90
91
  if (props.mode === 'tag') {
91
92
  const queue = target && DXN.tryParse(target) ? target : undefined;
92
- const query = queue ? Query.fromAst(newQuery).options({ queues: [queue] }) : Query.fromAst(newQuery);
93
- Obj.change(view, (v) => {
94
- v.query.ast = query.ast as Mutable<typeof query.ast>;
93
+ const query = queue ? Query.fromAst(newQuery).from({ queues: [queue] }) : Query.fromAst(newQuery);
94
+ Obj.change(view, (obj) => {
95
+ obj.query.ast = query.ast as Mutable<typeof query.ast>;
95
96
  });
96
97
 
97
98
  const typename = getTypenameFromQuery(query.ast);
@@ -100,17 +101,17 @@ const DefaultStory = (props: StoryProps) => {
100
101
  return;
101
102
  }
102
103
 
103
- const newView = View.make({
104
+ const newView = ViewModel.make({
104
105
  query,
105
106
  jsonSchema: newSchema.jsonSchema,
106
107
  });
107
- Obj.change(view, (v) => {
108
- v.projection = Obj.getSnapshot(newView).projection as Mutable<typeof v.projection>;
108
+ Obj.change(view, (obj) => {
109
+ obj.projection = Obj.getSnapshot(newView).projection as Mutable<typeof obj.projection>;
109
110
  });
110
111
  setSchema(() => newSchema);
111
112
  } else {
112
- Obj.change(view, (v) => {
113
- v.query.ast = newQuery as Mutable<typeof newQuery>;
113
+ Obj.change(view, (obj) => {
114
+ obj.query.ast = newQuery as Mutable<typeof newQuery>;
114
115
  });
115
116
  schema.updateTypename(getTypenameFromQuery(newQuery));
116
117
  }
@@ -175,7 +176,8 @@ const meta = {
175
176
  space.db.add(Tag.make({ label: 'New' }));
176
177
  },
177
178
  }),
178
- withTheme,
179
+ withTheme(),
180
+ withLayout({ layout: 'fullscreen' }),
179
181
  ],
180
182
  parameters: {
181
183
  layout: 'fullscreen',
@@ -6,8 +6,9 @@ import { composeStories } from '@storybook/react';
6
6
  import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
7
7
  import { afterEach, describe, expect, test } from 'vitest';
8
8
 
9
+ import { View } from '@dxos/echo';
9
10
  import { EchoSchema, isInstanceOf } from '@dxos/echo/internal';
10
- import { ProjectionModel, View } from '@dxos/schema';
11
+ import { ProjectionModel } from '@dxos/schema';
11
12
 
12
13
  import { VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
13
14
 
@@ -89,14 +90,10 @@ describe('ViewEditor', () => {
89
90
  await Default.run();
90
91
  await waitForViewEditor();
91
92
 
92
- // Click the add property button.
93
+ // Click the add property button. The new field is auto-expanded by handleAdd.
93
94
  const addButton = screen.getByText('Add property');
94
95
  fireEvent.click(addButton);
95
96
 
96
- // Open the last field.
97
- const buttons = screen.getAllByTestId('field.toggle');
98
- fireEvent.click(buttons.at(-1)!);
99
-
100
97
  // Fill out the property field.
101
98
  const fieldInput = screen.getByPlaceholderText('Property name');
102
99
  fireEvent.change(fieldInput, { target: { value: 'added_property' } });
@@ -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 { useObject } from '@dxos/react-client/echo';
16
- 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';
17
18
  import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
18
19
  import { List } from '@dxos/react-ui-list';
19
20
  import {
20
- FieldSchema,
21
- type FieldType,
21
+ ParentLabelAnnotation,
22
22
  ProjectionModel,
23
23
  VIEW_FIELD_LIMIT,
24
- type View,
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,
@@ -96,18 +94,31 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
96
94
  }, [atomRegistry, schema, view]);
97
95
 
98
96
  useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
99
- const [expandedField, setExpandedField] = useState<FieldType['id']>();
100
97
 
101
98
  const queueTarget = Match.value(view.query.ast).pipe(
102
- Match.when({ type: 'options' }, ({ options }) => {
103
- 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(
104
104
  Option.flatMap((queues) => Array.head(queues)),
105
+ Option.map(String),
105
106
  Option.getOrUndefined,
106
107
  );
107
108
  }),
108
109
  Match.orElse(() => undefined),
109
110
  );
110
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
+
111
122
  const viewSchema = useMemo(() => {
112
123
  const base = Schema.Struct({
113
124
  query:
@@ -119,7 +130,12 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
119
130
  if (mode === 'tag') {
120
131
  return Schema.Struct({
121
132
  ...base.fields,
122
- 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
+ ),
123
139
  }).pipe(Schema.mutable);
124
140
  }
125
141
 
@@ -131,9 +147,9 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
131
147
  const viewValues = useMemo(
132
148
  () => ({
133
149
  query: mode === 'schema' ? getTypenameFromQuery(view.query.ast) : view.query.ast,
134
- target: queueTarget,
150
+ target: targetRef,
135
151
  }),
136
- [mode, view.query.ast, queueTarget],
152
+ [mode, view.query.ast, targetRef],
137
153
  );
138
154
 
139
155
  const fieldMap = useMemo<FormFieldMap | undefined>(
@@ -141,49 +157,51 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
141
157
  [mode, types, tags],
142
158
  );
143
159
 
144
- // TODO(burdon): Check if mutable.
145
- const handleAdd = useCallback(() => {
146
- invariant(!readonly);
147
- const field = projectionModel.createFieldProjection();
148
- setExpandedField(field.id);
149
- }, [projectionModel, readonly]);
150
-
151
160
  const handleUpdate = useCallback(
152
161
  (values: any) => {
153
- requestAnimationFrame(() => {
154
- const query = mode === 'schema' ? Query.select(Filter.typename(values.query)).ast : values.query;
155
- onQueryChanged?.(query, values.target);
156
- });
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);
157
179
  },
158
- [onQueryChanged, view, queueTarget, mode],
180
+ [onQueryChanged, mode, feeds],
159
181
  );
160
182
 
161
183
  const handleDelete = useCallback(
162
184
  (fieldId: string) => {
163
185
  invariant(!readonly);
164
- if (fieldId === expandedField) {
165
- setExpandedField(undefined);
166
- }
167
-
168
186
  onDelete?.(fieldId);
169
187
  },
170
- [expandedField, onDelete, readonly],
188
+ [onDelete, readonly],
171
189
  );
172
190
 
173
191
  return (
174
192
  <div role='none' className={mx(classNames)}>
175
193
  {/* If readonly is set, then the callout is not needed. */}
176
194
  {schemaReadonly && !readonly && (
177
- <Callout.Root valence='info' classNames='mlb-cardSpacingBlock'>
178
- <Callout.Title>{t('system schema description')}</Callout.Title>
179
- </Callout.Root>
195
+ <Message.Root valence='info' classNames='my-form-padding'>
196
+ <Message.Title>{t('system-schema.description')}</Message.Title>
197
+ </Message.Root>
180
198
  )}
181
199
 
182
200
  {/* TODO(burdon): Is the form read-only or just the schema? */}
183
- <Form.Root schema={viewSchema} values={viewValues} fieldMap={fieldMap} autoSave onSave={handleUpdate}>
201
+ <Form.Root schema={viewSchema} values={viewValues} fieldMap={fieldMap} db={db} onValuesChanged={handleUpdate}>
184
202
  <Form.FieldSet />
185
203
 
186
- <FormFieldLabel label={t('fields label')} asChild />
204
+ <FormFieldLabel label={t('fields.label')} asChild />
187
205
  <FieldList
188
206
  schema={schema}
189
207
  view={view}
@@ -193,19 +211,6 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
193
211
  onDelete={handleDelete}
194
212
  />
195
213
  </Form.Root>
196
-
197
- {!readonly && !expandedField && (
198
- <div role='none' className='mlb-cardSpacingBlock'>
199
- <IconButton
200
- icon='ph--plus--regular'
201
- label={t('add property button label')}
202
- onClick={readonly ? undefined : handleAdd}
203
- // TODO(burdon): Show field limit in ux (not tooltip).
204
- disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
205
- classNames='is-full'
206
- />
207
- </div>
208
- )}
209
214
  </div>
210
215
  );
211
216
  },
@@ -239,10 +244,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
239
244
  return model;
240
245
  }, [atomRegistry, schema, view]);
241
246
 
242
- 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]);
243
254
 
244
255
  const handleToggleField = useCallback(
245
- (field: FieldType) => {
256
+ (field: View.FieldType) => {
246
257
  setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
247
258
  },
248
259
  [readonly],
@@ -263,12 +274,12 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
263
274
  const handleMove = useCallback(
264
275
  (fromIndex: number, toIndex: number) => {
265
276
  invariant(!readonly);
266
- Obj.change(view, (v) => {
277
+ Obj.change(view, (obj) => {
267
278
  // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
268
- const fields = [...v.projection.fields];
279
+ const fields = [...obj.projection.fields];
269
280
  const [moved] = fields.splice(fromIndex, 1);
270
281
  fields.splice(toIndex, 0, moved);
271
- v.projection.fields = fields;
282
+ obj.projection.fields = fields;
272
283
  });
273
284
  },
274
285
  [view, readonly],
@@ -297,32 +308,39 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
297
308
  }
298
309
 
299
310
  return (
300
- <List.Root<FieldType>
301
- items={viewSnapshot.projection.fields as FieldType[]}
302
- isItem={Schema.is(FieldSchema)}
311
+ <List.Root<View.FieldType>
312
+ items={viewSnapshot.projection.fields as View.FieldType[]}
313
+ isItem={Schema.is(View.FieldSchema)}
303
314
  getId={(field) => field.id}
304
315
  onMove={readonly ? undefined : handleMove}
305
316
  readonly={readonly}
306
317
  >
307
318
  {({ items: fields }) => (
308
319
  <>
309
- {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
310
- <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]'>
311
322
  {fields?.map((field) => {
312
323
  const hidden = field.visible === false;
313
324
  return (
314
- <List.Item<FieldType>
325
+ <List.Item<View.FieldType>
315
326
  key={field.id}
316
327
  item={field}
317
- classNames={listItemGrid}
328
+ classNames={'grid grid-cols-subgrid col-span-5'}
318
329
  aria-expanded={expandedField === field.id}
319
330
  >
320
- <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
+ >
321
339
  <List.ItemDragHandle disabled={readonly || schemaReadonly} />
322
340
  <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
323
341
  {field.path}
324
342
  </List.ItemTitle>
325
- <List.ItemButton
343
+ <List.ItemIconButton
326
344
  label={t(hidden ? 'show field label' : 'hide field label')}
327
345
  data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
328
346
  icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
@@ -333,16 +351,16 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
333
351
  {!readonly && (
334
352
  <>
335
353
  <List.ItemDeleteButton
336
- label={t('delete field label')}
354
+ label={t('delete-field.label')}
337
355
  autoHide={false}
338
356
  disabled={readonly || schemaReadonly || viewSnapshot.projection.fields.length <= 1}
339
357
  onClick={() => handleDelete(field.id)}
340
358
  data-testid='field.delete'
341
359
  />
342
- <IconButton
360
+ <List.ItemIconButton
343
361
  iconOnly
344
362
  variant='ghost'
345
- label={t('toggle expand label', { ns: osTranslations })}
363
+ label={t('toggle-expand.label', { ns: osTranslations })}
346
364
  icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
347
365
  onClick={() => handleToggleField(field)}
348
366
  data-testid='field.toggle'
@@ -351,7 +369,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
351
369
  )}
352
370
  </div>
353
371
  {expandedField === field.id && !readonly && (
354
- <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
372
+ <div role='none' className='col-span-5 mt-1 mb-1 border border-separator rounded-md'>
355
373
  <FieldEditor
356
374
  readonly={readonly || schemaReadonly}
357
375
  registry={registry}
@@ -365,6 +383,17 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
365
383
  );
366
384
  })}
367
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
+ )}
368
397
  </>
369
398
  )}
370
399
  </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';
@@ -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.
@@ -7,22 +7,13 @@ 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';
11
10
  import { type AnyProperties } from '@dxos/echo/internal';
12
- import {
13
- type JsonPath,
14
- createJsonPath,
15
- fromEffectValidationPath,
16
- getValue as getValue$,
17
- setValue as setValue$,
18
- } from '@dxos/effect';
11
+ import { type JsonPath, createJsonPath, fromEffectValidationPath, getValue as getValue$ } from '@dxos/effect';
19
12
  import { log } from '@dxos/log';
20
13
  import { useDefaultValue } from '@dxos/react-ui';
21
14
  import { type ValidationError, validateSchema } from '@dxos/schema';
22
15
  import { type MaybePromise } from '@dxos/util';
23
16
 
24
- import { setValueEchoAware } from '../util';
25
-
26
17
  export type FormFieldStatus = {
27
18
  status?: 'error';
28
19
  error?: string;
@@ -139,11 +130,15 @@ export const useFormHandler = <T extends AnyProperties>({
139
130
  prop: valuesProp,
140
131
  defaultProp: defaultValues,
141
132
  onChange: () => {
142
- setValues(valuesProp ?? defaultValues);
143
- setChanged({});
144
- setTouched({});
145
- setErrors({});
146
- 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
+ }
147
142
  },
148
143
  });
149
144
  // TODO(wittjosiah): Upgrade @radix-ui/react-use-controllable-state.
@@ -259,6 +254,7 @@ export const useFormHandler = <T extends AnyProperties>({
259
254
  log('onValueChange', { path, value });
260
255
 
261
256
  const jsonPath = createJsonPath(path);
257
+ const pathArray = path;
262
258
  let parsedValue = value as any;
263
259
  try {
264
260
  if (type._tag === 'NumberKeyword') {
@@ -269,24 +265,25 @@ export const useFormHandler = <T extends AnyProperties>({
269
265
  parsedValue = undefined;
270
266
  }
271
267
 
272
- // Update.
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) };
277
- 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>;
278
270
 
279
271
  // TODO(burdon): Check value has changed from original.
280
272
  const newChanged = { [jsonPath]: true };
281
- setChanged({ ...changed, ...newChanged });
273
+ setChanged((prev) => ({ ...prev, ...newChanged }));
282
274
 
283
275
  // Validate.
284
276
  const isValid = validate(newValues);
285
277
 
286
- // Callback.
278
+ // Notify parent; parent is responsible for applying the change.
287
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
+ }
288
285
  },
289
- [values, changed, validate, onValuesChanged],
286
+ [values, validate, onValuesChanged, valuesProp],
290
287
  );
291
288
 
292
289
  const onBlur = useCallback(
@@ -347,6 +344,27 @@ export const useFormHandler = <T extends AnyProperties>({
347
344
  );
348
345
  };
349
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
+
350
368
  const flatMap = (errors: ValidationError[]) => {
351
369
  return errors.reduce(
352
370
  (result, { path, message }) => {