@dxos/react-ui-form 0.8.2-main.f11618f → 0.8.2-staging.42af850

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 (98) hide show
  1. package/dist/lib/browser/index.mjs +1732 -1374
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +1715 -1365
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +1732 -1374
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/ControlSection/ControlSection.d.ts +1 -1
  11. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.test.d.ts +2 -0
  14. package/dist/types/src/components/FieldEditor/FieldEditor.test.d.ts.map +1 -0
  15. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Defaults.d.ts +4 -4
  17. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/Form.d.ts +4 -4
  19. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/Form.stories.d.ts +34 -34
  21. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormActions.d.ts +1 -1
  23. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormContent.d.ts +9 -8
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/RefField.d.ts +7 -18
  29. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/factory.d.ts +1 -1
  35. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  37. package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +2 -2
  39. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  40. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +5 -3
  41. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  42. package/dist/types/src/components/deprecated/Form.d.ts +2 -2
  43. package/dist/types/src/components/deprecated/Form.d.ts.map +1 -1
  44. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  46. package/dist/types/src/components/testing.d.ts.map +1 -1
  47. package/dist/types/src/hooks/index.d.ts +2 -0
  48. package/dist/types/src/hooks/index.d.ts.map +1 -1
  49. package/dist/types/src/hooks/useForm.d.ts +2 -2
  50. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  51. package/dist/types/src/hooks/useQueryRefOptions.d.ts +23 -0
  52. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -0
  53. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +8 -0
  54. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -0
  55. package/dist/types/src/translations.d.ts +5 -0
  56. package/dist/types/src/translations.d.ts.map +1 -1
  57. package/dist/types/src/util/field.d.ts.map +1 -1
  58. package/dist/types/src/util/formatting.d.ts.map +1 -1
  59. package/dist/types/src/util/index.d.ts +2 -0
  60. package/dist/types/src/util/index.d.ts.map +1 -1
  61. package/dist/types/src/util/refs.d.ts +9 -0
  62. package/dist/types/src/util/refs.d.ts.map +1 -0
  63. package/dist/types/src/util/schema.d.ts +3 -0
  64. package/dist/types/src/util/schema.d.ts.map +1 -0
  65. package/dist/types/src/vitest-setup.d.ts +2 -0
  66. package/dist/types/src/vitest-setup.d.ts.map +1 -0
  67. package/dist/types/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +28 -25
  69. package/src/components/ControlSection/ControlSection.stories.tsx +1 -1
  70. package/src/components/ControlSection/ControlSection.tsx +2 -2
  71. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  72. package/src/components/FieldEditor/FieldEditor.test.tsx +49 -0
  73. package/src/components/FieldEditor/FieldEditor.tsx +3 -2
  74. package/src/components/Form/ArrayField.tsx +60 -49
  75. package/src/components/Form/Defaults.tsx +77 -48
  76. package/src/components/Form/Form.stories.tsx +104 -122
  77. package/src/components/Form/Form.tsx +5 -6
  78. package/src/components/Form/FormActions.tsx +15 -18
  79. package/src/components/Form/FormContent.tsx +80 -54
  80. package/src/components/Form/FormContext.tsx +14 -8
  81. package/src/components/Form/Input.tsx +4 -10
  82. package/src/components/Form/RefField.tsx +167 -101
  83. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +6 -5
  84. package/src/components/Form/factory.tsx +1 -1
  85. package/src/components/Form/index.ts +0 -1
  86. package/src/components/ViewEditor/ViewEditor.stories.tsx +19 -9
  87. package/src/components/ViewEditor/ViewEditor.tsx +65 -35
  88. package/src/components/deprecated/Form.tsx +6 -6
  89. package/src/components/testing.tsx +5 -9
  90. package/src/hooks/index.ts +3 -1
  91. package/src/hooks/useForm.ts +4 -3
  92. package/src/hooks/useQueryRefOptions.ts +53 -0
  93. package/src/hooks/useRefQueryLookupHandler.ts +39 -0
  94. package/src/translations.ts +9 -2
  95. package/src/util/index.ts +3 -1
  96. package/src/util/refs.ts +38 -0
  97. package/src/util/schema.ts +14 -0
  98. package/src/vitest-setup.ts +11 -0
@@ -5,38 +5,40 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import { type Meta, type StoryObj } from '@storybook/react';
8
+ import { Schema } from 'effect';
8
9
  import React, { useCallback, useState } from 'react';
9
10
 
10
11
  import { ContactType } from '@dxos/client/testing';
11
- import { AST, type BaseObject, Expando, Format, getDXN, Ref, S, type TypeAnnotation } from '@dxos/echo-schema';
12
+ import { type BaseObject, Expando, Format, getDXN, Ref, type TypeAnnotation } from '@dxos/echo-schema';
12
13
  import { live } from '@dxos/live-object';
14
+ import { withSurfaceVariantsLayout } from '@dxos/react-ui/testing';
13
15
  import { Testing } from '@dxos/schema/testing';
14
- import { withLayout, withTheme } from '@dxos/storybook-utils';
16
+ import { withTheme } from '@dxos/storybook-utils';
15
17
 
16
18
  import { SelectInput } from './Defaults';
17
19
  import { Form, type FormProps } from './Form';
18
20
  import translations from '../../translations';
19
21
  import { TestLayout, TestPanel } from '../testing';
20
22
 
21
- const AddressSchema = S.Struct({
22
- street: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'Street' })),
23
- city: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'City' })),
24
- zip: S.optional(S.String.pipe(S.pattern(/^\d{5}(-\d{4})?$/)).annotations({ [AST.TitleAnnotationId]: 'ZIP' })),
25
- location: S.optional(Format.GeoPoint.annotations({ [AST.TitleAnnotationId]: 'Location' })),
26
- }).annotations({ [AST.TitleAnnotationId]: 'Address' });
23
+ const AddressSchema = Schema.Struct({
24
+ street: Schema.optional(Schema.String.annotations({ title: 'Street' })),
25
+ city: Schema.optional(Schema.String.annotations({ title: 'City' })),
26
+ zip: Schema.optional(Schema.String.pipe(Schema.pattern(/^\d{5}(-\d{4})?$/)).annotations({ title: 'ZIP' })),
27
+ location: Schema.optional(Format.GeoPoint.annotations({ title: 'Location' })),
28
+ }).annotations({ title: 'Address' });
27
29
 
28
30
  // TODO(burdon): Translations?
29
- const TestSchema = S.Struct({
30
- name: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'Name' })),
31
- active: S.optional(S.Boolean.annotations({ [AST.TitleAnnotationId]: 'Active' })),
32
- rank: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Rank' })),
33
- website: S.optional(Format.URL.annotations({ [AST.TitleAnnotationId]: 'Website' })),
34
- address: S.optional(AddressSchema),
35
- }).pipe(S.mutable);
31
+ const TestSchema = Schema.Struct({
32
+ name: Schema.optional(Schema.String.annotations({ title: 'Name' })),
33
+ active: Schema.optional(Schema.Boolean.annotations({ title: 'Active' })),
34
+ rank: Schema.optional(Schema.Number.annotations({ title: 'Rank' })),
35
+ website: Schema.optional(Format.URL.annotations({ title: 'Website' })),
36
+ address: Schema.optional(AddressSchema),
37
+ }).pipe(Schema.mutable);
36
38
 
37
- type TestType = S.Schema.Type<typeof TestSchema>;
39
+ type TestType = Schema.Schema.Type<typeof TestSchema>;
38
40
 
39
- type StoryProps<T extends BaseObject> = { schema: S.Schema<T> } & FormProps<T>;
41
+ type StoryProps<T extends BaseObject> = { schema: Schema.Schema<T> } & FormProps<T>;
40
42
 
41
43
  const DefaultStory = <T extends BaseObject>({ schema, values: initialValues, ...props }: StoryProps<T>) => {
42
44
  const [values, setValues] = useState(initialValues);
@@ -44,6 +46,15 @@ const DefaultStory = <T extends BaseObject>({ schema, values: initialValues, ...
44
46
  setValues(values);
45
47
  }, []);
46
48
 
49
+ return <Form<T> schema={schema} values={values} onSave={handleSave} {...props} />;
50
+ };
51
+
52
+ const DebugStory = <T extends BaseObject>({ schema, values: initialValues, ...props }: StoryProps<T>) => {
53
+ const [values, setValues] = useState(initialValues);
54
+ const handleSave = useCallback<NonNullable<FormProps<T>['onSave']>>((values) => {
55
+ setValues(values);
56
+ }, []);
57
+
47
58
  return (
48
59
  <TestLayout json={{ values, schema: schema.ast.toJSON() }}>
49
60
  <TestPanel>
@@ -56,11 +67,17 @@ const DefaultStory = <T extends BaseObject>({ schema, values: initialValues, ...
56
67
  const meta: Meta<StoryProps<any>> = {
57
68
  title: 'ui/react-ui-form/Form',
58
69
  component: Form,
59
- render: DefaultStory,
60
- decorators: [withLayout({ fullscreen: true, tooltips: true }), withTheme],
70
+ render: DebugStory,
71
+ decorators: [withTheme],
61
72
  parameters: {
62
73
  translations,
63
74
  },
75
+ argTypes: {
76
+ readonly: {
77
+ control: 'boolean',
78
+ description: 'Readonly',
79
+ },
80
+ },
64
81
  };
65
82
 
66
83
  export default meta;
@@ -68,6 +85,8 @@ export default meta;
68
85
  type Story<T extends BaseObject> = StoryObj<StoryProps<T>>;
69
86
 
70
87
  export const Default: Story<TestType> = {
88
+ render: DefaultStory,
89
+ decorators: [withSurfaceVariantsLayout(), withTheme],
71
90
  args: {
72
91
  schema: TestSchema,
73
92
  values: {
@@ -77,6 +96,7 @@ export const Default: Story<TestType> = {
77
96
  zip: '11205',
78
97
  },
79
98
  },
99
+ readonly: false,
80
100
  },
81
101
  };
82
102
 
@@ -85,8 +105,9 @@ export const Organization: Story<Testing.Organization> = {
85
105
  schema: Testing.OrganizationSchema,
86
106
  values: {
87
107
  name: 'DXOS',
88
- // website: 'https://dxos.org',
108
+ website: 'https://dxos.org',
89
109
  },
110
+ readonly: false,
90
111
  },
91
112
  };
92
113
 
@@ -95,9 +116,10 @@ export const OrganizationAutoSave: Story<Testing.Organization> = {
95
116
  schema: Testing.OrganizationSchema,
96
117
  values: {
97
118
  name: 'DXOS',
98
- // website: 'https://dxos.org',
119
+ website: 'https://dxos.org',
99
120
  },
100
121
  autoSave: true,
122
+ readonly: false,
101
123
  },
102
124
  };
103
125
 
@@ -113,96 +135,58 @@ export const OrganizationAutoSave: Story<Testing.Organization> = {
113
135
  // },
114
136
  // };
115
137
 
116
- //
117
- // TODO(burdon): Move into separate storybook and use test types.
118
- //
119
-
120
- const ShapeSchema = S.Struct({
121
- shape: S.optional(
122
- S.Union(
123
- S.Struct({
124
- type: S.Literal('circle').annotations({ [AST.TitleAnnotationId]: 'Type' }),
125
- radius: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Radius' })),
138
+ const ShapeSchema = Schema.Struct({
139
+ shape: Schema.optional(
140
+ Schema.Union(
141
+ Schema.Struct({
142
+ type: Schema.Literal('circle').annotations({ title: 'Type' }),
143
+ radius: Schema.optional(Schema.Number.annotations({ title: 'Radius' })),
126
144
  }),
127
- S.Struct({
128
- type: S.Literal('square').annotations({ [AST.TitleAnnotationId]: 'Type' }),
129
- size: S.optional(S.Number.pipe(S.nonNegative()).annotations({ [AST.TitleAnnotationId]: 'Size' })),
145
+ Schema.Struct({
146
+ type: Schema.Literal('square').annotations({ title: 'Type' }),
147
+ size: Schema.optional(Schema.Number.pipe(Schema.nonNegative()).annotations({ title: 'Size' })),
130
148
  }),
131
- ).annotations({ [AST.TitleAnnotationId]: 'Shape' }),
149
+ ).annotations({ title: 'Shape' }),
132
150
  ),
133
- }).pipe(S.mutable);
151
+ }).pipe(Schema.mutable);
134
152
 
135
- type ShapeType = S.Schema.Type<typeof ShapeSchema>;
153
+ type ShapeType = Schema.Schema.Type<typeof ShapeSchema>;
136
154
 
137
- type DiscriminatedUnionStoryProps = FormProps<ShapeType>;
138
-
139
- const DiscriminatedUnionStory = ({ values: initialValues }: DiscriminatedUnionStoryProps) => {
140
- const [values, setValues] = useState(initialValues);
141
- const handleSave = useCallback<NonNullable<FormProps<ShapeType>['onSave']>>((values) => {
142
- setValues(values);
143
- }, []);
144
-
145
- return (
146
- <TestLayout json={{ values, schema: ShapeSchema.ast.toJSON() }}>
147
- <TestPanel>
148
- <Form<ShapeType>
149
- schema={ShapeSchema}
150
- values={values}
151
- onSave={handleSave}
152
- Custom={{
153
- ['shape.type' as const]: (props) => (
154
- <SelectInput
155
- {...props}
156
- options={['circle', 'square'].map((value) => ({
157
- value,
158
- label: value,
159
- }))}
160
- />
161
- ),
162
- }}
163
- />
164
- </TestPanel>
165
- </TestLayout>
166
- );
167
- };
168
-
169
- export const DiscriminatedShape: StoryObj<DiscriminatedUnionStoryProps> = {
170
- render: DiscriminatedUnionStory,
155
+ export const DiscriminatedShape: Story<ShapeType> = {
171
156
  args: {
157
+ schema: ShapeSchema,
158
+ readonly: false,
172
159
  values: {
173
160
  shape: {
174
161
  type: 'circle',
175
162
  radius: 5,
176
163
  },
177
164
  },
165
+ Custom: {
166
+ ['shape.type' as const]: (props) => (
167
+ <SelectInput
168
+ {...props}
169
+ options={['circle', 'square'].map((value) => ({
170
+ value,
171
+ label: value,
172
+ }))}
173
+ />
174
+ ),
175
+ },
178
176
  },
179
177
  };
180
178
 
181
- const ArraysSchema = S.Struct({
182
- names: S.Array(S.String.pipe(S.nonEmptyString())),
183
- addresses: S.Array(AddressSchema),
184
- }).pipe(S.mutable);
179
+ const ArraysSchema = Schema.Struct({
180
+ names: Schema.Array(Schema.String.pipe(Schema.nonEmptyString())),
181
+ addresses: Schema.Array(AddressSchema),
182
+ }).pipe(Schema.mutable);
185
183
 
186
- type ArraysType = S.Schema.Type<typeof ArraysSchema>;
187
-
188
- const ArraysStory = ({ values: initialValues }: FormProps<ArraysType>) => {
189
- const [values, setValues] = useState(initialValues);
190
- const handleSave = useCallback<NonNullable<FormProps<ArraysType>['onSave']>>((values) => {
191
- setValues(values);
192
- }, []);
193
-
194
- return (
195
- <TestLayout json={{ values, schema: ArraysSchema.ast.toJSON() }}>
196
- <TestPanel>
197
- <Form<ArraysType> schema={ArraysSchema} values={values} onSave={handleSave} />
198
- </TestPanel>
199
- </TestLayout>
200
- );
201
- };
184
+ type ArraysType = Schema.Schema.Type<typeof ArraysSchema>;
202
185
 
203
186
  export const Arrays: StoryObj<FormProps<ArraysType>> = {
204
- render: ArraysStory,
205
187
  args: {
188
+ schema: ArraysSchema,
189
+ readonly: false,
206
190
  values: {
207
191
  names: ['Alice', 'Bob'],
208
192
  addresses: [],
@@ -210,53 +194,40 @@ export const Arrays: StoryObj<FormProps<ArraysType>> = {
210
194
  },
211
195
  };
212
196
 
213
- const ColorSchema = S.Struct({
214
- color: S.Union(S.Literal('red'), S.Literal('green'), S.Literal('blue')).annotations({
215
- [AST.TitleAnnotationId]: 'Color',
197
+ const ColorSchema = Schema.Struct({
198
+ color: Schema.Union(Schema.Literal('red'), Schema.Literal('green'), Schema.Literal('blue')).annotations({
199
+ title: 'Color',
216
200
  }),
217
- }).pipe(S.mutable);
218
-
219
- type ColorType = S.Schema.Type<typeof ColorSchema>;
220
-
221
- const EnumStory = ({ values: initialValues }: FormProps<ColorType>) => {
222
- const [values, setValues] = useState(initialValues);
223
- const handleSave = useCallback<NonNullable<FormProps<ColorType>['onSave']>>((values) => {
224
- setValues(values);
225
- }, []);
201
+ }).pipe(Schema.mutable);
226
202
 
227
- return (
228
- <TestLayout json={{ values, schema: ColorSchema.ast.toJSON() }}>
229
- <TestPanel>
230
- <Form<ColorType> schema={ColorSchema} values={values} onSave={handleSave} />
231
- </TestPanel>
232
- </TestLayout>
233
- );
234
- };
203
+ type ColorType = Schema.Schema.Type<typeof ColorSchema>;
235
204
 
236
205
  export const Enum: StoryObj<FormProps<ColorType>> = {
237
- render: EnumStory,
238
206
  args: {
207
+ schema: ColorSchema,
208
+ readonly: false,
239
209
  values: {
240
210
  color: 'red',
241
211
  },
242
212
  },
243
213
  };
244
214
 
245
- const RefSchema = S.Struct({
215
+ const RefSchema = Schema.Struct({
246
216
  contact: Ref(ContactType).annotations({ title: 'Contact Reference' }),
247
- optionalContact: S.optional(Ref(ContactType).annotations({ title: 'Optional Contact Reference' })),
248
- unknownExpando: S.optional(Ref(Expando).annotations({ title: 'Optional Ref to an Expando (DXN Input)' })),
217
+ optionalContact: Schema.optional(Ref(ContactType).annotations({ title: 'Optional Contact Reference' })),
218
+ refArray: Schema.optional(Schema.Array(Ref(ContactType))),
219
+ unknownExpando: Schema.optional(Ref(Expando).annotations({ title: 'Optional Ref to an Expando (DXN Input)' })),
249
220
  });
250
221
 
251
- const RefStory = ({ values: initialValues }: FormProps<any>) => {
222
+ const contact1 = live(ContactType, { identifiers: [] });
223
+ const contact2 = live(ContactType, { identifiers: [] });
224
+
225
+ const RefStory = ({ values: initialValues, readonly }: FormProps<any>) => {
252
226
  const [values, setValues] = useState(initialValues);
253
227
  const handleSave = useCallback<NonNullable<FormProps<any>['onSave']>>((values) => {
254
228
  setValues(values);
255
229
  }, []);
256
230
 
257
- const contact1 = live(ContactType, { identifiers: [] });
258
- const contact2 = live(ContactType, { identifiers: [] });
259
-
260
231
  const onQueryRefOptions = useCallback((typeInfo: TypeAnnotation) => {
261
232
  switch (typeInfo.typename) {
262
233
  case ContactType.typename:
@@ -272,7 +243,13 @@ const RefStory = ({ values: initialValues }: FormProps<any>) => {
272
243
  return (
273
244
  <TestLayout json={{ values, schema: RefSchema.ast.toJSON() }}>
274
245
  <TestPanel>
275
- <Form schema={RefSchema} values={values} onSave={handleSave} onQueryRefOptions={onQueryRefOptions} />
246
+ <Form
247
+ schema={RefSchema}
248
+ values={values}
249
+ readonly={readonly}
250
+ onSave={handleSave}
251
+ onQueryRefOptions={onQueryRefOptions}
252
+ />
276
253
  </TestPanel>
277
254
  </TestLayout>
278
255
  );
@@ -280,5 +257,10 @@ const RefStory = ({ values: initialValues }: FormProps<any>) => {
280
257
 
281
258
  export const Refs: StoryObj<FormProps<ContactType>> = {
282
259
  render: RefStory,
283
- args: { values: {} },
260
+ args: {
261
+ readonly: false,
262
+ values: {
263
+ refArray: [Ref.make(contact1), Ref.make(contact2)],
264
+ } as any,
265
+ },
284
266
  };
@@ -2,9 +2,10 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { type Schema } from 'effect';
5
6
  import React, { type ReactElement, useEffect, useMemo, useRef } from 'react';
6
7
 
7
- import { type BaseObject, type S, type PropertyKey } from '@dxos/echo-schema';
8
+ import { type BaseObject, type PropertyKey } from '@dxos/echo-schema';
8
9
  import { type ThemedClassName } from '@dxos/react-ui';
9
10
  import { mx } from '@dxos/react-ui-theme';
10
11
  import { type SchemaProperty } from '@dxos/schema';
@@ -13,14 +14,13 @@ import { FormActions } from './FormActions';
13
14
  import { FormFields } from './FormContent';
14
15
  import { FormProvider } from './FormContext';
15
16
  import { type InputProps, type InputComponent } from './Input';
16
- import { type QueryRefOptions } from './RefField';
17
- import { type FormOptions } from '../../hooks';
17
+ import { type FormOptions, type QueryRefOptions } from '../../hooks';
18
18
 
19
19
  export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
20
20
 
21
21
  export type ComponentLookup = (args: {
22
22
  prop: string;
23
- schema: S.Schema<any>;
23
+ schema: Schema.Schema<any>;
24
24
  inputProps: InputProps;
25
25
  }) => ReactElement | undefined;
26
26
 
@@ -95,8 +95,7 @@ export const Form = <T extends BaseObject>({
95
95
  onValid={onValid}
96
96
  onSave={onSave}
97
97
  >
98
- {/* TODO(burdon): Remove padding. */}
99
- <div ref={formRef} role='none' className={mx('p-2', classNames)} data-testid={testId}>
98
+ <div ref={formRef} role='none' className={mx('flex flex-col gap-4', classNames)} data-testid={testId}>
100
99
  <FormFields
101
100
  schema={schema}
102
101
  path={path}
@@ -5,7 +5,6 @@
5
5
  import React from 'react';
6
6
 
7
7
  import { IconButton, useTranslation } from '@dxos/react-ui';
8
- import { mx } from '@dxos/react-ui-theme';
9
8
 
10
9
  import { useFormContext } from './FormContext';
11
10
  import { translationKey } from '../../translations';
@@ -15,27 +14,25 @@ export type FormActionsProps = {
15
14
  onCancel?: () => void;
16
15
  };
17
16
 
18
- export const FormActions = ({ onCancel, readonly }: FormActionsProps) => {
17
+ export const FormActions = ({ readonly, onCancel }: FormActionsProps) => {
19
18
  const { t } = useTranslation(translationKey);
20
19
  const { canSave, handleSave } = useFormContext();
21
20
 
22
21
  return (
23
- <div role='none' className='mt-2 flex justify-center'>
24
- <div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
25
- {onCancel && !readonly && (
26
- <IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
27
- )}
28
- {handleSave && (
29
- <IconButton
30
- type='submit'
31
- data-testid='save-button'
32
- disabled={!canSave}
33
- icon='ph--check--regular'
34
- label={t('button save')}
35
- onClick={handleSave}
36
- />
37
- )}
38
- </div>
22
+ <div role='none' className='flex [&_button]:grow gap-1'>
23
+ {onCancel && !readonly && (
24
+ <IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
25
+ )}
26
+ {handleSave && (
27
+ <IconButton
28
+ type='submit'
29
+ data-testid='save-button'
30
+ disabled={!canSave}
31
+ icon='ph--check--regular'
32
+ label={t('button save')}
33
+ onClick={handleSave}
34
+ />
35
+ )}
39
36
  </div>
40
37
  );
41
38
  };
@@ -2,12 +2,12 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { pipe } from 'effect';
5
+ import { Schema, SchemaAST, pipe } from 'effect';
6
6
  import { capitalize } from 'effect/String';
7
7
  import React, { useMemo } from 'react';
8
8
 
9
- import { AST, S } from '@dxos/echo-schema';
10
9
  import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
10
+ import { type ThemedClassName } from '@dxos/react-ui';
11
11
  import { mx } from '@dxos/react-ui-theme';
12
12
  import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
13
13
  import { isNotFalsy } from '@dxos/util';
@@ -17,8 +17,10 @@ import { SelectInput } from './Defaults';
17
17
  import { type ComponentLookup } from './Form';
18
18
  import { useInputProps, useFormValues } from './FormContext';
19
19
  import { type InputComponent } from './Input';
20
- import { RefField, type QueryRefOptions } from './RefField';
20
+ import { RefField } from './RefField';
21
21
  import { getInputComponent } from './factory';
22
+ import { type QueryRefOptions } from '../../hooks';
23
+ import { getRefProps } from '../../util';
22
24
 
23
25
  export type FormFieldProps = {
24
26
  property: SchemaProperty<any>;
@@ -49,9 +51,13 @@ export const FormField = ({
49
51
  [examples, description],
50
52
  );
51
53
 
54
+ //
55
+ // Registry and Custom.
56
+ //
57
+
52
58
  const FoundComponent = lookupComponent?.({
53
59
  prop: name,
54
- schema: S.make(ast),
60
+ schema: Schema.make(ast),
55
61
  inputProps: {
56
62
  type,
57
63
  format,
@@ -63,7 +69,7 @@ export const FormField = ({
63
69
  });
64
70
 
65
71
  if (FoundComponent) {
66
- return <div role='none'>{FoundComponent}</div>;
72
+ return FoundComponent;
67
73
  }
68
74
 
69
75
  const jsonPath = createJsonPath(path ?? []);
@@ -82,79 +88,97 @@ export const FormField = ({
82
88
  );
83
89
  }
84
90
 
91
+ //
92
+ // Refs.
93
+ //
94
+
95
+ const refProps = getRefProps(property);
96
+ if (refProps) {
97
+ return (
98
+ <RefField
99
+ ast={refProps.ast}
100
+ array={refProps.isArray}
101
+ type={type}
102
+ format={format}
103
+ label={label}
104
+ placeholder={placeholder}
105
+ disabled={readonly}
106
+ inputOnly={inline}
107
+ onQueryRefOptions={onQueryRefOptions}
108
+ {...inputProps}
109
+ />
110
+ );
111
+ }
112
+
113
+ //
114
+ // Arrays.
115
+ //
116
+
85
117
  if (array) {
86
118
  return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
87
119
  }
88
120
 
121
+ //
122
+ // Standard Inputs.
123
+ //
124
+
89
125
  const InputComponent = getInputComponent(type, format);
90
126
  if (InputComponent) {
91
127
  return (
92
- <div role='none'>
93
- <InputComponent
94
- type={type}
95
- format={format}
96
- label={label}
97
- inputOnly={inline}
98
- placeholder={placeholder}
99
- disabled={readonly}
100
- {...inputProps}
101
- />
102
- </div>
128
+ <InputComponent
129
+ type={type}
130
+ format={format}
131
+ label={label}
132
+ inputOnly={inline}
133
+ placeholder={placeholder}
134
+ disabled={readonly}
135
+ {...inputProps}
136
+ />
103
137
  );
104
138
  }
105
139
 
106
- if (options) {
107
- return (
108
- <div role='none'>
109
- <SelectInput
110
- type={type}
111
- format={format}
112
- disabled={readonly}
113
- inputOnly={inline}
114
- label={label}
115
- options={options.map((option) => ({ value: option, label: String(option) }))}
116
- placeholder={placeholder}
117
- {...inputProps}
118
- />
119
- </div>
120
- );
121
- }
140
+ //
141
+ // Select.
142
+ //
122
143
 
123
- // TODO(ZaymonFC): Extract this to it's own component.
124
- if (format === 'ref') {
144
+ if (options) {
125
145
  return (
126
- <RefField
127
- ast={ast}
146
+ <SelectInput
128
147
  type={type}
148
+ format={format}
149
+ disabled={readonly}
150
+ inputOnly={inline}
129
151
  label={label}
130
- readonly={readonly}
152
+ options={options.map((option) => ({ value: option, label: String(option) }))}
131
153
  placeholder={placeholder}
132
- inline={inline}
133
- onQueryRefOptions={onQueryRefOptions}
134
- inputProps={inputProps}
154
+ {...inputProps}
135
155
  />
136
156
  );
137
157
  }
138
158
 
159
+ //
160
+ // Nested Objects.
161
+ //
162
+
139
163
  if (type === 'object') {
140
164
  const baseNode = findNode(ast, isDiscriminatedUnion);
141
165
  const typeLiteral = baseNode
142
166
  ? getDiscriminatedType(baseNode, inputProps.getValue() as any)
143
- : findNode(ast, AST.isTypeLiteral);
167
+ : findNode(ast, SchemaAST.isTypeLiteral);
144
168
 
145
169
  if (typeLiteral) {
146
170
  return (
147
- <div role='none'>
148
- {!inline && <h3 className='text-lg mbs-2 mbe-1'>{label}</h3>}
171
+ <>
172
+ {!inline && <h3 className='text-lg mbs-2 mbe-1 first:mbs-0'>{label}</h3>}
149
173
  <FormFields
150
- schema={S.make(typeLiteral)}
174
+ schema={Schema.make(typeLiteral)}
151
175
  path={path}
152
176
  readonly={readonly}
153
177
  onQueryRefOptions={onQueryRefOptions}
154
178
  Custom={Custom}
155
179
  lookupComponent={lookupComponent}
156
180
  />
157
- </div>
181
+ </>
158
182
  );
159
183
  }
160
184
  }
@@ -162,27 +186,28 @@ export const FormField = ({
162
186
  return null;
163
187
  };
164
188
 
165
- export type FormContentProps = {
166
- schema: S.Schema.All;
189
+ export type FormFieldsProps = ThemedClassName<{
190
+ Custom?: Partial<Record<string, InputComponent>>;
191
+ schema: Schema.Schema.All;
167
192
  path?: (string | number)[];
168
193
  filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
169
194
  sort?: string[];
170
195
  readonly?: boolean;
171
- onQueryRefOptions?: QueryRefOptions;
172
196
  lookupComponent?: ComponentLookup;
173
- Custom?: Partial<Record<string, InputComponent>>;
174
- };
197
+ onQueryRefOptions?: QueryRefOptions;
198
+ }>;
175
199
 
176
200
  export const FormFields = ({
201
+ classNames,
202
+ Custom,
177
203
  schema,
178
204
  path,
179
205
  filter,
180
206
  sort,
181
207
  readonly,
182
- onQueryRefOptions,
183
208
  lookupComponent,
184
- Custom,
185
- }: FormContentProps) => {
209
+ onQueryRefOptions,
210
+ }: FormFieldsProps) => {
186
211
  const values = useFormValues(path);
187
212
 
188
213
  const properties = useMemo(() => {
@@ -193,7 +218,8 @@ export const FormFields = ({
193
218
  }, [schema, values, filter, sort]);
194
219
 
195
220
  return (
196
- <div role='form' className={mx('flex flex-col w-full gap-2')}>
221
+ // TODO(burdon): Consistent padding (with composite forms).
222
+ <div role='form' className={mx('p-2 is-full', classNames)}>
197
223
  {properties
198
224
  .map((property) => {
199
225
  return (