@dxos/react-ui-form 0.8.4-main.84f28bd → 0.8.4-main.937b3ca

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 (194) hide show
  1. package/dist/lib/browser/index.mjs +2203 -2067
  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 +2203 -2067
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/ControlSection/ControlSection.d.ts +4 -4
  8. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
  9. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +84 -5
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +8 -8
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +84 -7
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form-old.stories.d.ts +131 -0
  16. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Form/Form.d.ts +81 -25
  18. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts +127 -55
  20. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField.d.ts +37 -0
  22. package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts +78 -0
  24. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
  25. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
  26. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormFieldSet.d.ts +19 -0
  28. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
  30. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
  32. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
  34. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +4 -0
  36. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
  38. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/fields/RefField.d.ts +14 -0
  40. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/fields/SelectField.d.ts +10 -0
  42. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
  44. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
  45. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +82 -0
  46. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
  48. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
  49. package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
  50. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
  51. package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
  52. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
  53. package/dist/types/src/components/Form/fields/index.d.ts +12 -0
  54. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
  55. package/dist/types/src/components/Form/index.d.ts +2 -4
  56. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  57. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +46 -0
  58. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +85 -0
  60. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  61. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  62. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  63. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +21 -9
  64. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  65. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +84 -9
  66. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +1 -1
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/components/testing.d.ts +4 -3
  70. package/dist/types/src/components/testing.d.ts.map +1 -1
  71. package/dist/types/src/hooks/index.d.ts +2 -3
  72. package/dist/types/src/hooks/index.d.ts.map +1 -1
  73. package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
  74. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useKeyHandler.d.ts +6 -0
  76. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
  77. package/dist/types/src/translations.d.ts +19 -10
  78. package/dist/types/src/translations.d.ts.map +1 -1
  79. package/dist/types/src/util/field.d.ts +3 -3
  80. package/dist/types/src/util/field.d.ts.map +1 -1
  81. package/dist/types/src/util/formatting.d.ts +2 -2
  82. package/dist/types/src/util/formatting.d.ts.map +1 -1
  83. package/dist/types/src/util/index.d.ts +1 -1
  84. package/dist/types/src/util/index.d.ts.map +1 -1
  85. package/dist/types/src/util/properties.d.ts +7 -0
  86. package/dist/types/src/util/properties.d.ts.map +1 -0
  87. package/dist/types/src/util/refs.d.ts +2 -3
  88. package/dist/types/src/util/refs.d.ts.map +1 -1
  89. package/dist/types/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +51 -40
  91. package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
  92. package/src/components/ControlSection/ControlSection.tsx +30 -16
  93. package/src/components/FieldEditor/FieldEditor.stories.tsx +53 -30
  94. package/src/components/FieldEditor/FieldEditor.test.tsx +14 -13
  95. package/src/components/FieldEditor/FieldEditor.tsx +87 -92
  96. package/src/components/Form/Form-old.stories.tsx +314 -0
  97. package/src/components/Form/Form.stories.tsx +155 -219
  98. package/src/components/Form/Form.tsx +319 -96
  99. package/src/components/Form/FormField.tsx +286 -0
  100. package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
  101. package/src/components/Form/FormFieldComponent.tsx +174 -0
  102. package/src/components/Form/FormFieldSet.tsx +125 -0
  103. package/src/components/Form/README.md +3 -0
  104. package/src/components/Form/fields/ArrayField.tsx +151 -0
  105. package/src/components/Form/fields/BooleanField.tsx +22 -0
  106. package/src/components/Form/fields/GeoPointField.tsx +120 -0
  107. package/src/components/Form/fields/MarkdownField.tsx +78 -0
  108. package/src/components/Form/fields/NumberField.tsx +39 -0
  109. package/src/components/Form/fields/RefField.tsx +181 -0
  110. package/src/components/Form/fields/SelectField.tsx +65 -0
  111. package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +25 -22
  112. package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +47 -42
  113. package/src/components/Form/fields/TextAreaField.tsx +38 -0
  114. package/src/components/Form/fields/TextField.tsx +38 -0
  115. package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +16 -9
  116. package/src/components/Form/fields/index.ts +15 -0
  117. package/src/components/Form/index.ts +3 -4
  118. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +214 -0
  119. package/src/components/ObjectPicker/ObjectPicker.tsx +175 -0
  120. package/src/components/ObjectPicker/index.ts +5 -0
  121. package/src/components/ViewEditor/ViewEditor.stories.tsx +141 -59
  122. package/src/components/ViewEditor/ViewEditor.test.tsx +25 -21
  123. package/src/components/ViewEditor/ViewEditor.tsx +317 -214
  124. package/src/components/index.ts +1 -2
  125. package/src/components/testing.tsx +19 -11
  126. package/src/hooks/index.ts +2 -3
  127. package/src/hooks/useFormHandler.ts +356 -0
  128. package/src/hooks/useKeyHandler.tsx +37 -0
  129. package/src/translations.ts +21 -12
  130. package/src/util/field.ts +33 -43
  131. package/src/util/formatting.ts +22 -22
  132. package/src/util/index.ts +1 -1
  133. package/src/util/properties.ts +18 -0
  134. package/src/util/refs.ts +12 -18
  135. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  136. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  137. package/dist/types/src/components/Form/Defaults.d.ts +0 -14
  138. package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
  139. package/dist/types/src/components/Form/FormActions.d.ts +0 -9
  140. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  141. package/dist/types/src/components/Form/FormContent.d.ts +0 -57
  142. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  143. package/dist/types/src/components/Form/FormContext.d.ts +0 -23
  144. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  145. package/dist/types/src/components/Form/Input.d.ts +0 -26
  146. package/dist/types/src/components/Form/Input.d.ts.map +0 -1
  147. package/dist/types/src/components/Form/RefField.d.ts +0 -12
  148. package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
  149. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
  150. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
  151. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  152. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  153. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -7
  154. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  155. package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
  156. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
  157. package/dist/types/src/components/Form/custom/index.d.ts +0 -4
  158. package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
  159. package/dist/types/src/components/Form/factory.d.ts +0 -8
  160. package/dist/types/src/components/Form/factory.d.ts.map +0 -1
  161. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  162. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  163. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  164. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  165. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  166. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  167. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  168. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  169. package/dist/types/src/hooks/useForm.d.ts +0 -67
  170. package/dist/types/src/hooks/useForm.d.ts.map +0 -1
  171. package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -23
  172. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
  173. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
  174. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
  175. package/dist/types/src/util/schema.d.ts +0 -3
  176. package/dist/types/src/util/schema.d.ts.map +0 -1
  177. package/src/components/Form/ArrayField.tsx +0 -124
  178. package/src/components/Form/Defaults.tsx +0 -203
  179. package/src/components/Form/FormActions.tsx +0 -53
  180. package/src/components/Form/FormContent.tsx +0 -240
  181. package/src/components/Form/FormContext.tsx +0 -110
  182. package/src/components/Form/Input.tsx +0 -51
  183. package/src/components/Form/RefField.tsx +0 -213
  184. package/src/components/Form/custom/GeoPointInput.tsx +0 -72
  185. package/src/components/Form/custom/index.ts +0 -7
  186. package/src/components/Form/factory.tsx +0 -31
  187. package/src/components/deprecated/Form.stories.tsx +0 -62
  188. package/src/components/deprecated/Form.tsx +0 -119
  189. package/src/components/deprecated/FormInput.tsx +0 -71
  190. package/src/components/deprecated/index.ts +0 -5
  191. package/src/hooks/useForm.ts +0 -291
  192. package/src/hooks/useQueryRefOptions.ts +0 -53
  193. package/src/hooks/useRefQueryLookupHandler.ts +0 -39
  194. package/src/util/schema.ts +0 -14
@@ -0,0 +1,286 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Match from 'effect/Match';
6
+ import * as Option from 'effect/Option';
7
+ import * as Schema from 'effect/Schema';
8
+ import * as SchemaAST from 'effect/SchemaAST';
9
+ import * as String from 'effect/String';
10
+ import React, { useMemo } from 'react';
11
+
12
+ import { Format } from '@dxos/echo';
13
+ import {
14
+ createJsonPath,
15
+ findNode,
16
+ getAnnotation,
17
+ getDiscriminatedType,
18
+ isArrayType,
19
+ isDiscriminatedUnion,
20
+ isLiteralUnion,
21
+ isNestedType,
22
+ } from '@dxos/effect';
23
+ import { useTranslation } from '@dxos/react-ui';
24
+ import { type ProjectionModel } from '@dxos/schema';
25
+
26
+ import { translationKey } from '../../translations';
27
+ import { getRefProps } from '../../util';
28
+
29
+ import {
30
+ ArrayField,
31
+ BooleanField,
32
+ GeoPointField,
33
+ MarkdownField,
34
+ NumberField,
35
+ RefField,
36
+ type RefFieldProps,
37
+ SelectField,
38
+ TextAreaField,
39
+ TextField,
40
+ } from './fields';
41
+ import { useFormFieldState } from './Form';
42
+ import {
43
+ type FormFieldComponent,
44
+ type FormFieldComponentProps,
45
+ type FormFieldMap,
46
+ type FormFieldProvider,
47
+ } from './FormFieldComponent';
48
+ import { FormFieldSet } from './FormFieldSet';
49
+
50
+ export type FormFieldProps = {
51
+ /**
52
+ * AST of the property to render.
53
+ */
54
+ type: SchemaAST.AST;
55
+
56
+ /**
57
+ * Name of the property.
58
+ */
59
+ name: string;
60
+
61
+ /**
62
+ * Path to the current object from the root. Used with nested forms.
63
+ */
64
+ path?: (string | number)[];
65
+
66
+ /**
67
+ * Optional projection for projection-based field management.
68
+ */
69
+ projection?: ProjectionModel;
70
+
71
+ /**
72
+ * Map of custom renderers for specific properties.
73
+ * Prefer fieldProvider for plugin specific input surfaces.
74
+ */
75
+ fieldMap?: FormFieldMap;
76
+
77
+ /**
78
+ * Function to lookup custom renderers for specific properties.
79
+ */
80
+ fieldProvider?: FormFieldProvider;
81
+ } & Pick<FormFieldComponentProps, 'autoFocus' | 'readonly' | 'layout'> &
82
+ Pick<
83
+ RefFieldProps,
84
+ | 'createOptionLabel'
85
+ | 'createOptionIcon'
86
+ | 'createInitialValuePath'
87
+ | 'db'
88
+ | 'schemaHook'
89
+ | 'getOptions'
90
+ | 'onCreate'
91
+ >;
92
+
93
+ export const FormField = (props: FormFieldProps) => {
94
+ const {
95
+ type,
96
+ name,
97
+ path,
98
+ projection,
99
+ fieldMap,
100
+ fieldProvider,
101
+ readonly,
102
+ layout,
103
+
104
+ // RefFieldProps
105
+ createOptionLabel,
106
+ createOptionIcon,
107
+ createInitialValuePath,
108
+ db,
109
+ schemaHook,
110
+ getOptions: getRefOptions,
111
+ onCreate,
112
+ } = props;
113
+ const { t } = useTranslation(translationKey);
114
+ const title = getAnnotation<string>(SchemaAST.TitleAnnotationId)(type);
115
+ const description = getAnnotation<string>(SchemaAST.DescriptionAnnotationId)(type);
116
+ const examples = getAnnotation<string[]>(SchemaAST.ExamplesAnnotationId)(type);
117
+
118
+ const label = useMemo(() => title ?? String.capitalize(name), [title, name]);
119
+ const placeholder = useMemo(
120
+ () => (examples?.length ? `${t('example placeholder')}: ${examples[0]}` : (description ?? label)),
121
+ [examples, description, label],
122
+ );
123
+
124
+ const fieldState = useFormFieldState(FormField.displayName, path);
125
+ const fieldProps: FormFieldComponentProps = {
126
+ type,
127
+ format: Format.FormatAnnotation.getFromAst(type).pipe((annotation) => Option.getOrUndefined(annotation)),
128
+ readonly,
129
+ label,
130
+ placeholder,
131
+ layout,
132
+ ...fieldState,
133
+ };
134
+
135
+ //
136
+ // Custom field.
137
+ //
138
+
139
+ const jsonPath = createJsonPath(path ?? []);
140
+ const CustomField = fieldMap?.[jsonPath];
141
+ if (CustomField) {
142
+ return <CustomField {...fieldProps} />;
143
+ }
144
+
145
+ // TODO(burdon): Expensive to create schema each time; pass AST?
146
+ const component = fieldProvider?.({ schema: Schema.make(type), prop: name, fieldProps });
147
+ if (component) {
148
+ return component;
149
+ }
150
+
151
+ //
152
+ // Array field.
153
+ //
154
+
155
+ if (isArrayType(type)) {
156
+ return <ArrayField fieldProps={fieldState} label={label} {...props} />;
157
+ }
158
+
159
+ //
160
+ // Regular field.
161
+ //
162
+
163
+ const Field = getFormField(fieldProps);
164
+ if (Field) {
165
+ return <Field {...fieldProps} />;
166
+ }
167
+
168
+ //
169
+ // Select field.
170
+ //
171
+
172
+ const options = getOptions(type);
173
+ if (options) {
174
+ return (
175
+ <SelectField
176
+ {...fieldProps}
177
+ options={options.map((option) => ({
178
+ value: option,
179
+ label: option.toString(),
180
+ }))}
181
+ />
182
+ );
183
+ }
184
+
185
+ //
186
+ // Ref field.
187
+ //
188
+
189
+ const refProps = getRefProps(type);
190
+ if (refProps) {
191
+ return (
192
+ <RefField
193
+ {...fieldProps}
194
+ {...refProps}
195
+ createOptionLabel={createOptionLabel}
196
+ createOptionIcon={createOptionIcon}
197
+ createInitialValuePath={createInitialValuePath}
198
+ db={db}
199
+ schemaHook={schemaHook}
200
+ getOptions={getRefOptions}
201
+ onCreate={onCreate}
202
+ />
203
+ );
204
+ }
205
+
206
+ //
207
+ // Nested Object field.
208
+ //
209
+
210
+ if (isNestedType(type)) {
211
+ const baseNode = findNode(type, isDiscriminatedUnion);
212
+ const typeLiteral = baseNode
213
+ ? getDiscriminatedType(baseNode, fieldState.getValue() as any)
214
+ : findNode(type, SchemaAST.isTypeLiteral);
215
+
216
+ if (typeLiteral) {
217
+ const schema = Schema.make(typeLiteral);
218
+ return (
219
+ <FormFieldSet
220
+ schema={schema}
221
+ path={path}
222
+ readonly={readonly}
223
+ layout={layout}
224
+ label={label}
225
+ projection={projection}
226
+ fieldMap={fieldMap}
227
+ fieldProvider={fieldProvider}
228
+ createOptionLabel={createOptionLabel}
229
+ createOptionIcon={createOptionIcon}
230
+ createInitialValuePath={createInitialValuePath}
231
+ db={db}
232
+ schemaHook={schemaHook}
233
+ getOptions={getRefOptions}
234
+ onCreate={onCreate}
235
+ />
236
+ );
237
+ }
238
+ }
239
+
240
+ return null;
241
+ };
242
+
243
+ FormField.displayName = 'Form.FormField';
244
+
245
+ /**
246
+ * Get property input component.
247
+ */
248
+ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldComponent | undefined => {
249
+ //
250
+ // Standard formats.
251
+ //
252
+
253
+ const formatField = Match.value(format).pipe(
254
+ Match.withReturnType<FormFieldComponent | undefined>(),
255
+ Match.when(Format.TypeFormat.GeoPoint, () => GeoPointField),
256
+ Match.when(Format.TypeFormat.Markdown, () => MarkdownField),
257
+ Match.when(Format.TypeFormat.Text, () => TextAreaField),
258
+ Match.orElse(() => undefined),
259
+ );
260
+ if (formatField) {
261
+ return formatField;
262
+ }
263
+
264
+ //
265
+ // Standard types.
266
+ //
267
+
268
+ switch (type._tag) {
269
+ // TODO(wittjosiah): Schema.Any is currently used to represent template inputs.
270
+ case 'AnyKeyword':
271
+ case 'StringKeyword':
272
+ return TextField;
273
+ case 'NumberKeyword':
274
+ return NumberField;
275
+ case 'BooleanKeyword':
276
+ return BooleanField;
277
+ }
278
+ };
279
+
280
+ const getOptions = (ast: SchemaAST.AST): Format.Options[] | undefined => {
281
+ if (isLiteralUnion(ast)) {
282
+ return ast.types.map((type) => type.literal).filter((v): v is string | number => v !== null);
283
+ }
284
+
285
+ return Format.OptionsAnnotation.getFromAst(ast).pipe((annotation) => Option.getOrUndefined(annotation));
286
+ };
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { type ChangeEvent, useCallback } from 'react';
7
+
8
+ import { Input } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { type FormFieldComponentProps, FormFieldWrapper } from './FormFieldComponent';
12
+
13
+ const Component = ({
14
+ type,
15
+ placeholder,
16
+ readonly,
17
+ onBlur,
18
+ onValueChange,
19
+ ...props
20
+ }: FormFieldComponentProps<string>) => {
21
+ const handleChange = useCallback(
22
+ (event: ChangeEvent<HTMLInputElement>) => onValueChange(type, event.target.value),
23
+ [type, onValueChange],
24
+ );
25
+
26
+ return (
27
+ <div className='plb-cardSpacingBlock pli-cardSpacingInline'>
28
+ <FormFieldWrapper<string> {...props}>
29
+ {({ value = '' }) => (
30
+ <Input.TextInput
31
+ disabled={!!readonly}
32
+ placeholder={placeholder}
33
+ noAutoFill
34
+ value={value}
35
+ onChange={handleChange}
36
+ onBlur={onBlur}
37
+ />
38
+ )}
39
+ </FormFieldWrapper>
40
+ </div>
41
+ );
42
+ };
43
+
44
+ const meta: Meta<typeof Component> = {
45
+ title: 'ui/react-ui-form/FormFieldComponent',
46
+ component: Component,
47
+ decorators: [withTheme, withLayout({ layout: 'column' })],
48
+ parameters: {
49
+ layout: 'fullscreen',
50
+ },
51
+ };
52
+
53
+ export default meta;
54
+
55
+ type Story = StoryObj<typeof meta>;
56
+
57
+ export const Default: Story = {
58
+ args: {
59
+ label: 'Label',
60
+ getStatus: () => ({ status: 'error', error: 'Required field' }),
61
+ getValue: () => 'DXOS',
62
+ onBlur: () => {},
63
+ onValueChange: () => {},
64
+ },
65
+ };
@@ -0,0 +1,174 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type * as Schema from 'effect/Schema';
6
+ import type * as SchemaAST from 'effect/SchemaAST';
7
+ import React, {
8
+ Component,
9
+ type FC,
10
+ type FocusEvent,
11
+ type PropsWithChildren,
12
+ type ReactElement,
13
+ type ReactNode,
14
+ } from 'react';
15
+
16
+ import { type Format } from '@dxos/echo/internal';
17
+ import { Icon, Input, Tooltip } from '@dxos/react-ui';
18
+ import { errorText, mx } from '@dxos/ui-theme';
19
+
20
+ import { type FormFieldStatus } from '../../hooks';
21
+
22
+ const labelSpacing = 'mbs-inputSpacingBlock mbe-labelSpacingBlock';
23
+
24
+ /**
25
+ * Dynamic props passed to input components.
26
+ */
27
+ export type FormFieldStateProps<T = any> = {
28
+ getStatus: () => FormFieldStatus;
29
+ getValue: () => T | undefined;
30
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
31
+ onValueChange: (type: SchemaAST.AST, value: T) => void;
32
+ };
33
+
34
+ /**
35
+ * Presentation mode.
36
+ *
37
+ * - full: Show label, control, and status.
38
+ * - compact: Show label and control.
39
+ * - inline: Control only.
40
+ * - static: Plain DOM; omit all undefined values.
41
+ */
42
+ // TODO(burdon): 'inline' should be orthogonal to the layout (e.g., array of inline static).
43
+ export type Presentation = 'full' | 'compact' | 'inline' | 'static';
44
+
45
+ /**
46
+ * Props passed to input components.
47
+ */
48
+ export type FormFieldComponentProps<T = any> = {
49
+ type: SchemaAST.AST;
50
+ format?: Format.TypeFormat;
51
+ readonly?: boolean;
52
+ label: string;
53
+ placeholder?: string;
54
+ autoFocus?: boolean;
55
+ layout?: Presentation;
56
+ } & FormFieldStateProps<T>;
57
+
58
+ /**
59
+ * Form field component.
60
+ */
61
+ export type FormFieldComponent = FC<FormFieldComponentProps>;
62
+
63
+ export type FormFieldMap = Record<string, FormFieldComponent>;
64
+
65
+ export type FormFieldProvider = (props: {
66
+ prop: string; // TODO(burdon): Path?
67
+ schema: Schema.Schema<any>;
68
+ fieldProps: FormFieldComponentProps;
69
+ }) => ReactElement | undefined;
70
+
71
+ //
72
+ // FormFieldLabel
73
+ //
74
+
75
+ export type FormFieldLabelProps = {
76
+ asChild?: boolean;
77
+ error?: string;
78
+ } & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
79
+
80
+ export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
81
+ const Label = readonly || asChild ? 'span' : Input.Label;
82
+ const labelProps = readonly || asChild ? { className: 'text-description text-sm' } : { classNames: '!mlb-0 text-sm' };
83
+
84
+ return (
85
+ <div role='none' className={mx('flex justify-between items-center', labelSpacing)}>
86
+ <Label {...labelProps}>{label}</Label>
87
+ {error && (
88
+ <Tooltip.Trigger asChild content={error} side='bottom'>
89
+ <Icon icon='ph--warning--regular' size={4} classNames={errorText} />
90
+ </Tooltip.Trigger>
91
+ )}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ FormFieldLabel.displayName = 'Form.FieldLabel';
97
+
98
+ //
99
+ // FormFieldWrapper
100
+ //
101
+
102
+ export type FormFieldWrapperProps<T = any> = Pick<
103
+ FormFieldComponentProps,
104
+ 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
105
+ > & {
106
+ children?: (props: { value: T }) => ReactNode;
107
+ };
108
+
109
+ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
110
+ const { children, readonly, layout, label, getStatus, getValue } = props;
111
+ const { status, error } = getStatus();
112
+
113
+ const value = getValue();
114
+ if (layout === 'static' && value == null) {
115
+ return null;
116
+ }
117
+
118
+ const str = String(value ?? '');
119
+
120
+ return (
121
+ <Input.Root validationValence={status}>
122
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
123
+ {layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
124
+ {layout === 'full' && (
125
+ <Input.DescriptionAndValidation>
126
+ <Input.Validation>{error}</Input.Validation>
127
+ </Input.DescriptionAndValidation>
128
+ )}
129
+ </Input.Root>
130
+ );
131
+ };
132
+
133
+ //
134
+ // FormFieldErrorBoundary
135
+ //
136
+
137
+ type FormFieldErrorState = {
138
+ error: Error | undefined;
139
+ };
140
+
141
+ type FormFieldErrorBoundaryProps = PropsWithChildren<{
142
+ path?: (string | number)[];
143
+ }>;
144
+
145
+ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProps, FormFieldErrorState> {
146
+ static getDerivedStateFromError(error: Error): { error: Error } {
147
+ return { error };
148
+ }
149
+
150
+ override state = { error: undefined };
151
+
152
+ override componentDidUpdate(prevProps: FormFieldErrorBoundaryProps) {
153
+ if (prevProps.path !== this.props.path) {
154
+ this.resetError();
155
+ }
156
+ }
157
+
158
+ override render() {
159
+ if (this.state.error) {
160
+ return (
161
+ <div className='flex gap-2 border border-roseFill font-mono text-sm'>
162
+ <span className='bg-roseFill text-surfaceText pli-1 font-thin'>ERROR</span>
163
+ {String(this.props.path?.join('.'))}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ return this.props.children;
169
+ }
170
+
171
+ private resetError() {
172
+ this.setState({ error: undefined });
173
+ }
174
+ }
@@ -0,0 +1,125 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { forwardRef, useMemo } from 'react';
6
+
7
+ import { type AnyProperties } from '@dxos/echo/internal';
8
+ import { type SchemaProperty } from '@dxos/effect';
9
+ import { type ThemedClassName } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
11
+ import { isTruthy } from '@dxos/util';
12
+
13
+ import { type FormHandlerProps } from '../../hooks';
14
+ import { getFormProperties } from '../../util';
15
+
16
+ import { useFormValues } from './Form';
17
+ import { FormField, type FormFieldProps } from './FormField';
18
+ import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
19
+
20
+ export type FormFieldSetProps<T extends AnyProperties> = ThemedClassName<
21
+ {
22
+ label?: string;
23
+ exclude?: (props: SchemaProperty[]) => SchemaProperty[];
24
+ sort?: string[];
25
+ } & Pick<FormHandlerProps<T>, 'schema'> &
26
+ Pick<
27
+ FormFieldProps,
28
+ | 'path'
29
+ | 'autoFocus'
30
+ | 'readonly'
31
+ | 'layout'
32
+ | 'projection'
33
+ | 'fieldMap'
34
+ | 'fieldProvider'
35
+ | 'createOptionLabel'
36
+ | 'createOptionIcon'
37
+ | 'createInitialValuePath'
38
+ | 'db'
39
+ | 'schemaHook'
40
+ | 'getOptions'
41
+ | 'onCreate'
42
+ >
43
+ >;
44
+
45
+ export const FormFieldSet = forwardRef<HTMLDivElement, FormFieldSetProps<any>>(
46
+ ({ classNames, label, schema, readonly, path, exclude, sort, projection, layout, ...props }, forwardRef) => {
47
+ const values = useFormValues(FormFieldSet.displayName!, path);
48
+
49
+ // TODO(burdon): Updates on every value change.
50
+ // Remove values dep if can remove from getSchemaProperties.
51
+ const properties = useMemo(() => {
52
+ if (!schema) {
53
+ return [];
54
+ }
55
+
56
+ // TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
57
+ const props = getFormProperties(schema.ast);
58
+
59
+ // Use projection-based field management when view and projection are available.
60
+ if (projection) {
61
+ const fieldProjections = projection.getFieldProjections();
62
+ const hiddenProperties = new Set(projection.getHiddenProperties());
63
+
64
+ // Filter properties to only include visible ones and order by projection.
65
+ const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name.toString()));
66
+ const orderedProps: SchemaProperty[] = [];
67
+
68
+ // Add properties in projection field order.
69
+ for (const fieldProjection of fieldProjections) {
70
+ const fieldPath = String(fieldProjection.field.path);
71
+ const prop = visibleProps.find((p) => p.name === fieldPath);
72
+ if (prop) {
73
+ orderedProps.push(prop);
74
+ }
75
+ }
76
+
77
+ // Add any remaining properties not in projection.
78
+ const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
79
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
80
+ orderedProps.push(...remainingProps);
81
+ return orderedProps;
82
+ }
83
+
84
+ // Fallback to legacy filter/sort behavior.
85
+ const filtered = exclude ? exclude(props) : props;
86
+ return sort
87
+ ? filtered.sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
88
+ : filtered;
89
+ }, [schema, values, exclude, sort, projection?.fields]);
90
+
91
+ if ((readonly || layout === 'static') && values == null) {
92
+ return null;
93
+ }
94
+
95
+ return (
96
+ <div
97
+ role='form'
98
+ className={mx('is-full', layout === 'inline' && 'flex flex-col gap-2', classNames)}
99
+ ref={forwardRef}
100
+ >
101
+ {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
102
+ {properties
103
+ .map((property) => {
104
+ const name = property.name.toString();
105
+ return (
106
+ <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
107
+ <FormField
108
+ type={property.type}
109
+ name={name}
110
+ path={[...(path ?? []), name]}
111
+ readonly={readonly}
112
+ layout={layout}
113
+ projection={projection}
114
+ {...props}
115
+ />
116
+ </FormFieldErrorBoundary>
117
+ );
118
+ })
119
+ .filter(isTruthy)}
120
+ </div>
121
+ );
122
+ },
123
+ );
124
+
125
+ FormFieldSet.displayName = 'Form.Fields';
@@ -0,0 +1,3 @@
1
+ # Form
2
+
3
+ - [ ] Layout options (e.g., label on the side.)