@dxos/react-ui-form 0.8.4-main.d05539e30a → 0.8.4-main.d9fc60f731
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.
- package/dist/lib/browser/index.mjs +1335 -561
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +2 -1
- package/dist/lib/browser/translations.mjs.map +2 -2
- package/dist/lib/node-esm/index.mjs +1335 -561
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +2 -1
- package/dist/lib/node-esm/translations.mjs.map +2 -2
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +2 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +35 -9
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +76 -16
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +14 -4
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +22 -10
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet.d.ts +8 -2
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
- package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
- package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +119 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
- package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
- package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
- package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
- package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
- package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +2 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts +4 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +2 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/index.d.ts +2 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/index.d.ts +3 -0
- package/dist/types/src/components/Form/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
- package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
- package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +3 -4
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +2 -2
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +2 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +2 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
- package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts +2 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +7 -8
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +4 -4
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +9 -0
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -1
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +11 -0
- package/dist/types/src/util/properties.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +37 -32
- package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
- package/src/components/FieldEditor/FieldEditor.test.tsx +4 -1
- package/src/components/FieldEditor/FieldEditor.tsx +19 -14
- package/src/components/Form/Form.stories.tsx +21 -27
- package/src/components/Form/Form.tsx +106 -19
- package/src/components/Form/FormField.tsx +85 -28
- package/src/components/Form/FormFieldComponent.tsx +75 -20
- package/src/components/Form/FormFieldSet.tsx +32 -3
- package/src/components/Form/FormTooltipsContext.ts +25 -0
- package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
- package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
- package/src/components/Form/Layout/FormLayout.test.ts +59 -0
- package/src/components/Form/Layout/FormLayout.tsx +251 -0
- package/src/components/Form/Layout/annotation.ts +38 -0
- package/src/components/Form/Layout/index.ts +7 -0
- package/src/components/Form/Layout/parser.test.ts +120 -0
- package/src/components/Form/Layout/parser.ts +167 -0
- package/src/components/Form/fields/ArrayField.tsx +61 -38
- package/src/components/Form/fields/DateField.tsx +101 -0
- package/src/components/Form/fields/GeoPointField.tsx +4 -1
- package/src/components/Form/fields/InlineRefField.tsx +146 -0
- package/src/components/Form/fields/MarkdownField.tsx +3 -3
- package/src/components/Form/fields/RefField.tsx +29 -23
- package/src/components/Form/fields/SelectField.tsx +17 -5
- package/src/components/Form/fields/SelectOptionField.tsx +4 -3
- package/src/components/Form/fields/TextAreaField.tsx +1 -1
- package/src/components/Form/fields/TupleField.tsx +2 -1
- package/src/components/Form/fields/index.ts +2 -0
- package/src/components/Form/index.ts +3 -0
- package/src/components/Form/meta-tags.test.ts +47 -0
- package/src/components/Form/meta-tags.ts +33 -0
- package/src/components/ObjectForm/ObjectForm.tsx +21 -26
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +2 -2
- package/src/components/ObjectPicker/ObjectPicker.tsx +2 -2
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +14 -26
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +20 -10
- package/src/components/ObjectProperties/ObjectProperties.tsx +26 -30
- package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
- package/src/components/ObjectTree/ObjectTree.tsx +190 -0
- package/src/components/ObjectTree/index.ts +5 -0
- package/src/components/Settings/Settings.tsx +2 -1
- package/src/components/Settings/SettingsItem.tsx +1 -1
- package/src/components/ViewEditor/ViewEditor.stories.tsx +35 -40
- package/src/components/ViewEditor/ViewEditor.test.tsx +11 -8
- package/src/components/ViewEditor/ViewEditor.tsx +29 -32
- package/src/components/index.ts +1 -0
- package/src/components/testing.tsx +4 -4
- package/src/translations.ts +2 -1
- package/src/util/properties.test.ts +67 -9
- package/src/util/properties.ts +36 -4
- package/src/vite-env.d.ts +10 -0
|
@@ -7,9 +7,9 @@ import * as Option from 'effect/Option';
|
|
|
7
7
|
import * as Schema from 'effect/Schema';
|
|
8
8
|
import * as SchemaAST from 'effect/SchemaAST';
|
|
9
9
|
import * as String from 'effect/String';
|
|
10
|
-
import React, { useMemo } from 'react';
|
|
10
|
+
import React, { PropsWithChildren, useMemo } from 'react';
|
|
11
11
|
|
|
12
|
-
import { Format } from '@dxos/echo';
|
|
12
|
+
import { Annotation, Format } from '@dxos/echo';
|
|
13
13
|
import {
|
|
14
14
|
createJsonPath,
|
|
15
15
|
findNode,
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
isLiteralUnion,
|
|
21
21
|
isNestedType,
|
|
22
22
|
} from '@dxos/effect';
|
|
23
|
-
import { useTranslation } from '@dxos/react-ui';
|
|
23
|
+
import { IconButton, IconButtonProps, useTranslation } from '@dxos/react-ui';
|
|
24
24
|
import { type ProjectionModel } from '@dxos/schema';
|
|
25
|
+
import { mx } from '@dxos/ui-theme';
|
|
25
26
|
|
|
26
27
|
import { translationKey } from '#translations';
|
|
27
28
|
|
|
@@ -29,7 +30,9 @@ import { getRefProps } from '../../util';
|
|
|
29
30
|
import {
|
|
30
31
|
ArrayField,
|
|
31
32
|
BooleanField,
|
|
33
|
+
DateField,
|
|
32
34
|
GeoPointField,
|
|
35
|
+
InlineRefField,
|
|
33
36
|
MarkdownField,
|
|
34
37
|
NumberField,
|
|
35
38
|
RefField,
|
|
@@ -54,9 +57,14 @@ export type FormFieldProps = {
|
|
|
54
57
|
type: SchemaAST.AST;
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* Name of the property.
|
|
60
|
+
* Name of the property. Used to derive a default label
|
|
61
|
+
* (`title ?? capitalize(name)`) and as the projection lookup key. Pass
|
|
62
|
+
* `null` to suppress the header label entirely -- the form still renders
|
|
63
|
+
* the field/struct, but `FormFieldSet`'s top-level `<FormFieldLabel>` is
|
|
64
|
+
* skipped. Used by `ArrayField` for object-array items, where every item
|
|
65
|
+
* would otherwise repeat the array's parent name.
|
|
58
66
|
*/
|
|
59
|
-
name: string;
|
|
67
|
+
name: string | null;
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
70
|
* Path to the current object from the root. Used with nested forms.
|
|
@@ -78,6 +86,7 @@ export type FormFieldProps = {
|
|
|
78
86
|
* Function to lookup custom renderers for specific properties.
|
|
79
87
|
*/
|
|
80
88
|
fieldProvider?: FormFieldProvider;
|
|
89
|
+
|
|
81
90
|
/**
|
|
82
91
|
* Typename of the ref type that the create props apply to.
|
|
83
92
|
* When set, createOptionLabel/createOptionIcon/createInitialValuePath/createFieldMap/onCreate
|
|
@@ -92,7 +101,7 @@ export type FormFieldProps = {
|
|
|
92
101
|
| 'createInitialValuePath'
|
|
93
102
|
| 'createFieldMap'
|
|
94
103
|
| 'db'
|
|
95
|
-
| '
|
|
104
|
+
| 'useType'
|
|
96
105
|
| 'getOptions'
|
|
97
106
|
| 'onCreate'
|
|
98
107
|
>;
|
|
@@ -115,7 +124,7 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
115
124
|
createInitialValuePath,
|
|
116
125
|
createFieldMap,
|
|
117
126
|
db,
|
|
118
|
-
|
|
127
|
+
useType: schemaHook,
|
|
119
128
|
getOptions,
|
|
120
129
|
onCreate,
|
|
121
130
|
} = props;
|
|
@@ -124,36 +133,53 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
124
133
|
const description = getAnnotation<string>(SchemaAST.DescriptionAnnotationId)(type);
|
|
125
134
|
const examples = getAnnotation<string[]>(SchemaAST.ExamplesAnnotationId)(type);
|
|
126
135
|
|
|
127
|
-
|
|
136
|
+
// `name === null` means "no header" -- collapse to an empty string so
|
|
137
|
+
// downstream consumers keep their `label: string` types, and the falsy
|
|
138
|
+
// value lets `FormFieldSet`'s `label && <FormFieldLabel ...>` guard skip
|
|
139
|
+
// the header.
|
|
140
|
+
const label = useMemo(() => title ?? (name == null ? '' : String.capitalize(name)), [title, name]);
|
|
128
141
|
const placeholder = useMemo(
|
|
129
142
|
() => (examples?.length ? `${t('example.placeholder')}: ${examples[0]}` : (description ?? label)),
|
|
130
143
|
[examples, description, label],
|
|
131
144
|
);
|
|
132
145
|
|
|
133
146
|
const fieldState = useFormFieldState(FormField.displayName, path);
|
|
147
|
+
const jsonPath = createJsonPath(path ?? []);
|
|
134
148
|
const fieldProps: FormFieldComponentProps = {
|
|
135
149
|
type,
|
|
136
150
|
format: Format.FormatAnnotation.getFromAst(type).pipe((annotation) => Option.getOrUndefined(annotation)),
|
|
137
151
|
readonly,
|
|
138
152
|
label,
|
|
153
|
+
jsonPath,
|
|
139
154
|
placeholder,
|
|
140
155
|
layout,
|
|
141
156
|
db,
|
|
142
157
|
...fieldState,
|
|
143
158
|
};
|
|
144
159
|
|
|
160
|
+
// Omit empty fields entirely in read-only mode -- an empty value has nothing
|
|
161
|
+
// to display, so a labelled row with a blank input is just noise. This
|
|
162
|
+
// mirrors what `FormFieldWrapper` already does for `layout === 'static'`, but
|
|
163
|
+
// covers every field type (including those that bypass the wrapper:
|
|
164
|
+
// RefField, SelectField, MarkdownField, ...). Container fields
|
|
165
|
+
// (`ArrayField`, nested-struct -> `FormFieldSet`) keep their own
|
|
166
|
+
// empty-value checks, but those branches only apply when the value is
|
|
167
|
+
// actually a non-null array/object, so this check doesn't interfere.
|
|
168
|
+
if (readonly && fieldState.getValue() == null) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
//
|
|
146
173
|
// Custom field.
|
|
147
174
|
//
|
|
148
175
|
|
|
149
|
-
const jsonPath = createJsonPath(path ?? []);
|
|
150
176
|
const CustomField = fieldMap?.[jsonPath];
|
|
151
177
|
if (CustomField) {
|
|
152
178
|
return <CustomField {...fieldProps} />;
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
// TODO(burdon): Expensive to create schema each time; pass AST?
|
|
156
|
-
const component = fieldProvider?.({ schema: Schema.make(type), prop: name, fieldProps });
|
|
182
|
+
const component = fieldProvider?.({ schema: Schema.make(type), prop: name ?? '', fieldProps });
|
|
157
183
|
if (component) {
|
|
158
184
|
return component;
|
|
159
185
|
}
|
|
@@ -206,6 +232,12 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
206
232
|
|
|
207
233
|
const refProps = getRefProps(type);
|
|
208
234
|
if (refProps) {
|
|
235
|
+
// Inline a single referenced object's own fields (nested form) instead of a picker.
|
|
236
|
+
const inline = Annotation.FormInlineAnnotation.getFromAst(refProps.ast).pipe(Option.getOrElse(() => false));
|
|
237
|
+
if (inline && !refProps.isArray) {
|
|
238
|
+
return <InlineRefField {...fieldProps} {...refProps} db={db} useType={schemaHook} onCreate={onCreate} />;
|
|
239
|
+
}
|
|
240
|
+
|
|
209
241
|
const isCreateTarget = !createTypename || refProps.typename === createTypename;
|
|
210
242
|
return (
|
|
211
243
|
<RefField
|
|
@@ -216,7 +248,7 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
216
248
|
createInitialValuePath={isCreateTarget ? createInitialValuePath : undefined}
|
|
217
249
|
createFieldMap={isCreateTarget ? createFieldMap : undefined}
|
|
218
250
|
db={db}
|
|
219
|
-
|
|
251
|
+
useType={schemaHook}
|
|
220
252
|
getOptions={getOptions}
|
|
221
253
|
onCreate={onCreate}
|
|
222
254
|
/>
|
|
@@ -236,23 +268,25 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
236
268
|
if (typeLiteral) {
|
|
237
269
|
const schema = Schema.make(typeLiteral);
|
|
238
270
|
return (
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
271
|
+
<Nesting>
|
|
272
|
+
<FormFieldSet
|
|
273
|
+
schema={schema}
|
|
274
|
+
path={path}
|
|
275
|
+
readonly={readonly}
|
|
276
|
+
layout={layout}
|
|
277
|
+
label={label}
|
|
278
|
+
projection={projection}
|
|
279
|
+
fieldMap={fieldMap}
|
|
280
|
+
fieldProvider={fieldProvider}
|
|
281
|
+
createOptionLabel={createOptionLabel}
|
|
282
|
+
createOptionIcon={createOptionIcon}
|
|
283
|
+
createInitialValuePath={createInitialValuePath}
|
|
284
|
+
db={db}
|
|
285
|
+
useType={schemaHook}
|
|
286
|
+
getOptions={getOptions}
|
|
287
|
+
onCreate={onCreate}
|
|
288
|
+
/>
|
|
289
|
+
</Nesting>
|
|
256
290
|
);
|
|
257
291
|
}
|
|
258
292
|
}
|
|
@@ -262,6 +296,26 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
262
296
|
|
|
263
297
|
FormField.displayName = 'Form.FormField';
|
|
264
298
|
|
|
299
|
+
//
|
|
300
|
+
// Layout components
|
|
301
|
+
//
|
|
302
|
+
|
|
303
|
+
// TODO(burdon): Options (nested or flat).
|
|
304
|
+
// TODO(burdon): Support collapsible.
|
|
305
|
+
export const Nesting = ({ children }: PropsWithChildren) => (
|
|
306
|
+
<div className='px-2 border border-subdued-separator rounded-sm mt-2'>
|
|
307
|
+
<div className='pb-2'>{children}</div>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
export const IconBlock = ({ inline, ...props }: IconButtonProps & { inline?: boolean }) => {
|
|
312
|
+
return (
|
|
313
|
+
<div className={mx('h-full flex px-1', inline ? 'items-center' : 'flex-col pt-2.5')}>
|
|
314
|
+
<IconButton variant='ghost' density='xs' square iconOnly {...props} />
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
265
319
|
/**
|
|
266
320
|
* Get property input component.
|
|
267
321
|
*/
|
|
@@ -272,9 +326,12 @@ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldCompo
|
|
|
272
326
|
|
|
273
327
|
const formatField = Match.value(format).pipe(
|
|
274
328
|
Match.withReturnType<FormFieldComponent | undefined>(),
|
|
329
|
+
Match.when(Format.TypeFormat.Date, () => DateField),
|
|
330
|
+
Match.when(Format.TypeFormat.DateTime, () => DateField),
|
|
275
331
|
Match.when(Format.TypeFormat.GeoPoint, () => GeoPointField),
|
|
276
332
|
Match.when(Format.TypeFormat.Markdown, () => MarkdownField),
|
|
277
333
|
Match.when(Format.TypeFormat.Text, () => TextAreaField),
|
|
334
|
+
Match.when(Format.TypeFormat.Time, () => DateField),
|
|
278
335
|
Match.orElse(() => undefined),
|
|
279
336
|
);
|
|
280
337
|
if (formatField) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { format as formatDate } from 'date-fns';
|
|
5
6
|
import type * as Schema from 'effect/Schema';
|
|
6
7
|
import type * as SchemaAST from 'effect/SchemaAST';
|
|
7
8
|
import React, {
|
|
@@ -13,12 +14,13 @@ import React, {
|
|
|
13
14
|
type ReactNode,
|
|
14
15
|
} from 'react';
|
|
15
16
|
|
|
16
|
-
import { type Database } from '@dxos/echo';
|
|
17
|
-
import { type Format } from '@dxos/echo/internal';
|
|
17
|
+
import { type Database, Format } from '@dxos/echo';
|
|
18
18
|
import { Icon, Input, Tooltip } from '@dxos/react-ui';
|
|
19
|
-
import { inputTextLabel } from '@dxos/ui
|
|
19
|
+
import { inputTextLabel } from '@dxos/react-ui';
|
|
20
|
+
import { mx } from '@dxos/ui-theme';
|
|
20
21
|
|
|
21
22
|
import { type FormFieldStatus } from '../../hooks';
|
|
23
|
+
import { useFormTooltips } from './FormTooltipsContext';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Dynamic props passed to input components.
|
|
@@ -44,29 +46,33 @@ export type Presentation = 'full' | 'compact' | 'inline' | 'static';
|
|
|
44
46
|
* Props passed to input components.
|
|
45
47
|
*/
|
|
46
48
|
export type FormFieldComponentProps<T = any> = {
|
|
49
|
+
/** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
|
|
50
|
+
db?: Database.Database;
|
|
47
51
|
type: SchemaAST.AST;
|
|
48
52
|
format?: Format.TypeFormat;
|
|
49
53
|
readonly?: boolean;
|
|
50
54
|
label: string;
|
|
55
|
+
/**
|
|
56
|
+
* Dotted JSON path of this field within the form values (e.g.
|
|
57
|
+
* `runtime.client.storage.persistent`). Forwarded to `FormFieldLabel` so
|
|
58
|
+
* that it can render the path as a debug hint when the enclosing
|
|
59
|
+
* `Form.Root` has `debug` enabled.
|
|
60
|
+
*/
|
|
61
|
+
jsonPath?: string;
|
|
51
62
|
placeholder?: string;
|
|
52
63
|
autoFocus?: boolean;
|
|
53
64
|
layout?: Presentation;
|
|
54
|
-
/** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
|
|
55
|
-
db?: Database.Database;
|
|
56
65
|
} & FormFieldStateProps<T>;
|
|
57
66
|
|
|
58
|
-
/**
|
|
59
|
-
* Form field component.
|
|
60
|
-
*/
|
|
61
67
|
export type FormFieldComponent = FC<FormFieldComponentProps>;
|
|
62
68
|
|
|
63
69
|
export type FormFieldMap = Record<string, FormFieldComponent>;
|
|
64
70
|
|
|
65
71
|
export type FormFieldProvider = (props: {
|
|
66
|
-
prop: string;
|
|
72
|
+
prop: string;
|
|
67
73
|
schema: Schema.Schema<any>;
|
|
68
74
|
fieldProps: FormFieldComponentProps;
|
|
69
|
-
}) => ReactElement | undefined;
|
|
75
|
+
}) => ReactElement | null | undefined;
|
|
70
76
|
|
|
71
77
|
//
|
|
72
78
|
// FormFieldLabel
|
|
@@ -75,13 +81,30 @@ export type FormFieldProvider = (props: {
|
|
|
75
81
|
export type FormFieldLabelProps = {
|
|
76
82
|
asChild?: boolean;
|
|
77
83
|
error?: string;
|
|
84
|
+
/**
|
|
85
|
+
* JSON path of the field this label describes (e.g. `runtime.client.storage.persistent`).
|
|
86
|
+
* Surfaced as a hover tooltip on the label when the enclosing `Form.Root`
|
|
87
|
+
* has `tooltips` enabled (the default) -- useful for spotting which field
|
|
88
|
+
* maps to which path when authoring schemas or filing bugs against a form.
|
|
89
|
+
* Callers can supply the path unconditionally; the label suppresses the
|
|
90
|
+
* tooltip when `Form.Root tooltips={false}`.
|
|
91
|
+
*/
|
|
92
|
+
path?: string;
|
|
78
93
|
} & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
|
|
79
94
|
|
|
80
|
-
export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
|
|
95
|
+
export const FormFieldLabel = ({ label, error, readonly, asChild, path }: FormFieldLabelProps) => {
|
|
96
|
+
const tooltips = useFormTooltips();
|
|
81
97
|
const Label = readonly || asChild ? 'span' : Input.Label;
|
|
98
|
+
const labelNode = <Label className={mx(inputTextLabel, 'text-sm')}>{label}</Label>;
|
|
82
99
|
return (
|
|
83
100
|
<div className='flex items-center justify-between'>
|
|
84
|
-
|
|
101
|
+
{tooltips && path ? (
|
|
102
|
+
<Tooltip.Trigger asChild content={path} side='bottom'>
|
|
103
|
+
{labelNode}
|
|
104
|
+
</Tooltip.Trigger>
|
|
105
|
+
) : (
|
|
106
|
+
labelNode
|
|
107
|
+
)}
|
|
85
108
|
{error && (
|
|
86
109
|
<Tooltip.Trigger asChild content={error} side='bottom'>
|
|
87
110
|
<Icon icon='ph--warning--regular' size={4} classNames='text-error-text' />
|
|
@@ -99,13 +122,39 @@ FormFieldLabel.displayName = 'Form.FieldLabel';
|
|
|
99
122
|
|
|
100
123
|
export type FormFieldWrapperProps<T = any> = Pick<
|
|
101
124
|
FormFieldComponentProps,
|
|
102
|
-
'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
|
|
125
|
+
'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue' | 'jsonPath' | 'format'
|
|
103
126
|
> & {
|
|
104
127
|
children?: (props: { value: T }) => ReactNode;
|
|
105
128
|
};
|
|
106
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Formats a value for `static` (read-only, plain-DOM) presentation based on its
|
|
132
|
+
* type `format`. Dates/times are rendered human-readable; everything else falls
|
|
133
|
+
* back to `String(value)`.
|
|
134
|
+
*/
|
|
135
|
+
const formatStaticValue = (value: unknown, format?: Format.TypeFormat): string => {
|
|
136
|
+
if (value == null) {
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (format) {
|
|
141
|
+
case Format.TypeFormat.DateTime:
|
|
142
|
+
case Format.TypeFormat.Date:
|
|
143
|
+
case Format.TypeFormat.Time: {
|
|
144
|
+
const date = new Date(value as string);
|
|
145
|
+
if (Number.isNaN(date.getTime())) {
|
|
146
|
+
return String(value);
|
|
147
|
+
}
|
|
148
|
+
const pattern = format === Format.TypeFormat.DateTime ? 'PPp' : format === Format.TypeFormat.Date ? 'PP' : 'p';
|
|
149
|
+
return formatDate(date, pattern);
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
return String(value);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
107
156
|
export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
|
|
108
|
-
const { children, readonly, layout, label, getStatus, getValue } = props;
|
|
157
|
+
const { children, readonly, layout, label, jsonPath, format, getStatus, getValue } = props;
|
|
109
158
|
const { status, error } = getStatus();
|
|
110
159
|
|
|
111
160
|
const value = getValue();
|
|
@@ -113,14 +162,20 @@ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
|
|
|
113
162
|
return null;
|
|
114
163
|
}
|
|
115
164
|
|
|
116
|
-
const str =
|
|
165
|
+
const str = formatStaticValue(value, format);
|
|
117
166
|
|
|
118
167
|
return (
|
|
119
168
|
<div className='contents'>
|
|
120
169
|
<Input.Root validationValence={status}>
|
|
121
|
-
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
|
|
122
|
-
{layout === 'static' ?
|
|
123
|
-
|
|
170
|
+
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
|
|
171
|
+
{layout === 'static' ? (
|
|
172
|
+
<p className='truncate min-w-0' title={str}>
|
|
173
|
+
{str}
|
|
174
|
+
</p>
|
|
175
|
+
) : children ? (
|
|
176
|
+
children({ value })
|
|
177
|
+
) : null}
|
|
178
|
+
{layout === 'full' && error && (
|
|
124
179
|
<Input.DescriptionAndValidation>
|
|
125
180
|
<Input.Validation>{error}</Input.Validation>
|
|
126
181
|
</Input.DescriptionAndValidation>
|
|
@@ -158,8 +213,8 @@ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProp
|
|
|
158
213
|
override render() {
|
|
159
214
|
if (this.state.error) {
|
|
160
215
|
return (
|
|
161
|
-
<div className='flex gap-2 border border-rose-
|
|
162
|
-
<span className='bg-rose-
|
|
216
|
+
<div className='flex gap-2 border border-rose-bg font-mono text-sm'>
|
|
217
|
+
<span className='bg-rose-bg text-base-fg px-1 font-thin'>ERROR</span>
|
|
163
218
|
{String(this.props.path?.join('.'))}
|
|
164
219
|
</div>
|
|
165
220
|
);
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
5
6
|
import React, { useMemo } from 'react';
|
|
6
7
|
|
|
7
8
|
import { type AnyProperties } from '@dxos/echo/internal';
|
|
8
|
-
import { type SchemaProperty } from '@dxos/effect';
|
|
9
|
+
import { createJsonPath, type SchemaProperty } from '@dxos/effect';
|
|
9
10
|
|
|
10
11
|
import { type FormHandlerProps } from '../../hooks';
|
|
11
12
|
import { getFormProperties } from '../../util';
|
|
12
13
|
import { useFormValues } from './Form';
|
|
13
14
|
import { FormField, type FormFieldProps } from './FormField';
|
|
14
15
|
import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
|
|
16
|
+
import { DEFAULT_LAYOUT_NAME, FormLayout, FormLayoutAnnotation } from './Layout';
|
|
15
17
|
|
|
16
18
|
const FORM_FIELDSET_NAME = 'Form.FieldSet';
|
|
17
19
|
|
|
@@ -19,6 +21,12 @@ export type FormFieldSetProps<T extends AnyProperties> = {
|
|
|
19
21
|
label?: string;
|
|
20
22
|
sort?: string[];
|
|
21
23
|
exclude?: (props: SchemaProperty[]) => SchemaProperty[];
|
|
24
|
+
/**
|
|
25
|
+
* Picks a named layout out of `FormLayoutAnnotation` when present.
|
|
26
|
+
* Falls back to `'default'`. Ignored when the schema has no annotation
|
|
27
|
+
* (linear rendering then takes over).
|
|
28
|
+
*/
|
|
29
|
+
layoutName?: string;
|
|
22
30
|
} & Pick<FormHandlerProps<T>, 'schema'> &
|
|
23
31
|
Pick<
|
|
24
32
|
FormFieldProps,
|
|
@@ -35,7 +43,7 @@ export type FormFieldSetProps<T extends AnyProperties> = {
|
|
|
35
43
|
| 'createInitialValuePath'
|
|
36
44
|
| 'createFieldMap'
|
|
37
45
|
| 'db'
|
|
38
|
-
| '
|
|
46
|
+
| 'useType'
|
|
39
47
|
| 'getOptions'
|
|
40
48
|
| 'onCreate'
|
|
41
49
|
>;
|
|
@@ -52,6 +60,7 @@ export const FormFieldSet = ({
|
|
|
52
60
|
exclude,
|
|
53
61
|
projection,
|
|
54
62
|
layout,
|
|
63
|
+
layoutName = DEFAULT_LAYOUT_NAME,
|
|
55
64
|
...props
|
|
56
65
|
}: FormFieldSetProps<any>) => {
|
|
57
66
|
const values = useFormValues(FORM_FIELDSET_NAME, path);
|
|
@@ -60,9 +69,29 @@ export const FormFieldSet = ({
|
|
|
60
69
|
return null;
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
// If the schema carries a layout template, hand off to <Form.Layout/> which renders the DSL.
|
|
73
|
+
// Linear rendering still runs when no annotation is present, so existing call sites are unchanged.
|
|
74
|
+
const layouts = schema ? Option.getOrUndefined(FormLayoutAnnotation.get(schema)) : undefined;
|
|
75
|
+
if (layouts?.[layoutName] !== undefined && schema) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
|
|
79
|
+
<FormLayout
|
|
80
|
+
schema={schema}
|
|
81
|
+
name={layoutName}
|
|
82
|
+
path={path}
|
|
83
|
+
readonly={readonly}
|
|
84
|
+
layout={layout}
|
|
85
|
+
projection={projection}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
return (
|
|
64
93
|
<>
|
|
65
|
-
{layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
|
|
94
|
+
{layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
|
|
66
95
|
{properties.map((property) => {
|
|
67
96
|
const name = property.name.toString();
|
|
68
97
|
return (
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createContext, useContext } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A standalone context for the `tooltips` flag (Form.Root's only label
|
|
9
|
+
* decoration today: a JSON-path hover tooltip rendered by
|
|
10
|
+
* `FormFieldLabel`).
|
|
11
|
+
*
|
|
12
|
+
* Lives in its own module so that `FormFieldComponent.tsx` -- imported at
|
|
13
|
+
* the top of `Form.tsx` -- can read the flag without re-importing from
|
|
14
|
+
* `Form.tsx`. That round-trip used to create a circular module-eval
|
|
15
|
+
* dependency that intermittently hit a TDZ ("Cannot access 'FormFieldLabel'
|
|
16
|
+
* before initialization") in browser-mode storybook tests.
|
|
17
|
+
*
|
|
18
|
+
* Defaults to `true` both when `Form.Root` doesn't pass an explicit value
|
|
19
|
+
* AND when there is no enclosing `Form.Root` (e.g. `FormFieldWrapper`
|
|
20
|
+
* stories that render an input without a Form provider).
|
|
21
|
+
*/
|
|
22
|
+
export const FormTooltipsContext = createContext<boolean>(true);
|
|
23
|
+
|
|
24
|
+
/** Returns whether the enclosing `Form.Root` has field tooltips enabled. */
|
|
25
|
+
export const useFormTooltips = (): boolean => useContext(FormTooltipsContext);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { composeStories } from '@storybook/react';
|
|
6
|
+
import { act, cleanup, screen, within } from '@testing-library/react';
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import * as stories from './FormLayout.stories';
|
|
10
|
+
|
|
11
|
+
const { NestedLabel, NestedLabelStatic } = composeStories(stories);
|
|
12
|
+
|
|
13
|
+
describe('FormLayout — nested fields', () => {
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
// Flush pending React scheduler work before teardown to prevent
|
|
16
|
+
// "window is not defined" errors from setImmediate callbacks firing after happy-dom cleanup.
|
|
17
|
+
await act(async () => {});
|
|
18
|
+
cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('renders nested struct labels and dotted sub-fields', { timeout: 30_000 }, async () => {
|
|
22
|
+
await NestedLabel.run();
|
|
23
|
+
|
|
24
|
+
// `<field name="origin"/>` / `<field name="destination"/>` auto-convert to the
|
|
25
|
+
// place's `LabelAnnotation` label (the `name` field), rendered as read-only text.
|
|
26
|
+
// `findByText` retries while the async client provider initializes.
|
|
27
|
+
expect(await screen.findByText('John F. Kennedy Intl')).toBeInTheDocument();
|
|
28
|
+
expect(await screen.findByText('Charles de Gaulle')).toBeInTheDocument();
|
|
29
|
+
|
|
30
|
+
// `<field name="origin.code"/>` / `<field name="destination.code"/>` drill into the
|
|
31
|
+
// leaf sub-field, rendered as editable inputs holding the code.
|
|
32
|
+
expect(await screen.findByDisplayValue('JFK')).toBeInTheDocument();
|
|
33
|
+
expect(await screen.findByDisplayValue('CDG')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('static layout formats dates and renders labels as read-only text', { timeout: 30_000 }, async () => {
|
|
37
|
+
await NestedLabelStatic.run();
|
|
38
|
+
|
|
39
|
+
// Scope assertions to the form: the debug panel echoes the raw values JSON.
|
|
40
|
+
const form = await screen.findByRole('form');
|
|
41
|
+
|
|
42
|
+
// The depart date renders human-readable (contains the year) rather than the raw ISO
|
|
43
|
+
// string. Match `2026` and assert no ISO time fragment leaks through — timezone-robust.
|
|
44
|
+
expect(within(form).getByText(/2026/)).toBeInTheDocument();
|
|
45
|
+
expect(within(form).queryByText(/T\d\d:\d\d/)).toBeNull();
|
|
46
|
+
|
|
47
|
+
// Nested struct labels and dotted sub-fields render as static text (not inputs).
|
|
48
|
+
expect(within(form).getByText('John F. Kennedy Intl')).toBeInTheDocument();
|
|
49
|
+
expect(within(form).getByText('JFK')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
});
|