@dxos/react-ui-form 0.8.4-main.ead640a → 0.8.4-main.effb148878
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/LICENSE +102 -5
- package/README.md +0 -1
- package/dist/lib/browser/index.mjs +3252 -2315
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +83 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +3252 -2315
- 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 +85 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +8 -7
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +69 -60
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +123 -26
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +175 -138
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +48 -0
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +95 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts +21 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
- 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 +25 -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 +116 -0
- package/dist/types/src/components/Form/Layout/FormLayout.stories.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 +18 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
- 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 +5 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +11 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +85 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts +22 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts +13 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +85 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/index.d.ts +13 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/index.d.ts +4 -4
- package/dist/types/src/components/Form/index.d.ts.map +1 -1
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +8 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
- package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +37 -10
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +65 -59
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +128 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
- 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 +51 -0
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts +85 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
- package/dist/types/src/components/Settings/index.d.ts +4 -0
- package/dist/types/src/components/Settings/index.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +16 -20
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +72 -66
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +4 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +13 -6
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +2 -3
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts +7 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +66 -60
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/field-type.d.ts +19 -0
- package/dist/types/src/util/field-type.d.ts.map +1 -0
- package/dist/types/src/util/field.d.ts +3 -3
- package/dist/types/src/util/field.d.ts.map +1 -1
- package/dist/types/src/util/formatting.d.ts +2 -2
- package/dist/types/src/util/formatting.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +2 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +17 -0
- package/dist/types/src/util/properties.d.ts.map +1 -0
- package/dist/types/src/util/properties.test.d.ts +2 -0
- package/dist/types/src/util/properties.test.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +2 -2
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +61 -51
- package/src/components/FieldEditor/FieldEditor.stories.tsx +44 -25
- package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
- package/src/components/FieldEditor/FieldEditor.tsx +84 -84
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +134 -252
- package/src/components/Form/Form.tsx +417 -107
- package/src/components/Form/FormField.tsx +332 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +206 -0
- package/src/components/Form/FormFieldSet.tsx +173 -0
- package/src/components/Form/FormTooltipsContext.ts +25 -0
- package/src/components/Form/Layout/FormLayout.stories.tsx +339 -0
- package/src/components/Form/Layout/FormLayout.tsx +123 -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/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +185 -0
- package/src/components/Form/fields/BooleanField.tsx +22 -0
- package/src/components/Form/fields/DateField.tsx +95 -0
- package/src/components/Form/fields/GeoPointField.tsx +124 -0
- package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
- package/src/components/Form/fields/MarkdownField.tsx +168 -0
- package/src/components/Form/fields/NumberField.tsx +39 -0
- package/src/components/Form/fields/RefField.tsx +232 -0
- package/src/components/Form/fields/SelectField.tsx +77 -0
- package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +23 -18
- package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +31 -24
- package/src/components/Form/fields/TextAreaField.tsx +38 -0
- package/src/components/Form/fields/TextField.tsx +38 -0
- package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +17 -9
- package/src/components/Form/fields/index.ts +16 -0
- package/src/components/Form/index.ts +5 -4
- package/src/components/ObjectForm/ObjectForm.tsx +115 -0
- package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +30 -25
- package/src/components/ObjectPicker/ObjectPicker.tsx +122 -81
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +350 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +108 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +147 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- 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/AUDIT.md +51 -0
- package/src/components/Settings/Settings.stories.tsx +104 -0
- package/src/components/Settings/Settings.tsx +81 -0
- package/src/components/Settings/SettingsFieldSet.tsx +257 -0
- package/src/components/Settings/SettingsItem.tsx +58 -0
- package/src/components/Settings/SettingsSelectField.tsx +59 -0
- package/src/components/Settings/index.ts +7 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +65 -73
- package/src/components/ViewEditor/ViewEditor.test.tsx +11 -11
- package/src/components/ViewEditor/ViewEditor.tsx +308 -214
- package/src/components/index.ts +4 -1
- package/src/components/testing.tsx +24 -11
- package/src/hooks/index.ts +2 -3
- package/src/hooks/useFormHandler.ts +380 -0
- package/src/hooks/useKeyHandler.tsx +37 -0
- package/src/index.ts +0 -4
- package/src/translations.ts +71 -65
- package/src/util/field-type.ts +60 -0
- package/src/util/field.ts +25 -25
- package/src/util/formatting.ts +22 -22
- package/src/util/index.ts +2 -1
- package/src/util/properties.test.ts +78 -0
- package/src/util/properties.ts +38 -0
- package/src/util/refs.ts +16 -18
- package/src/vite-env.d.ts +10 -0
- package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
- package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -84
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/index.d.ts +0 -2
- package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
- package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
- package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
- package/dist/types/src/components/Form/Defaults.d.ts +0 -14
- package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormActions.d.ts +0 -9
- package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContent.d.ts +0 -63
- package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContext.d.ts +0 -23
- package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
- package/dist/types/src/components/Form/Input.d.ts +0 -29
- package/dist/types/src/components/Form/Input.d.ts.map +0 -1
- package/dist/types/src/components/Form/RefField.d.ts +0 -22
- package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -79
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
- package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/index.d.ts +0 -4
- package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
- package/dist/types/src/components/Form/factory.d.ts +0 -8
- package/dist/types/src/components/Form/factory.d.ts.map +0 -1
- package/dist/types/src/hooks/useForm.d.ts +0 -67
- package/dist/types/src/hooks/useForm.d.ts.map +0 -1
- package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -29
- package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
- package/dist/types/src/util/schema.d.ts +0 -3
- package/dist/types/src/util/schema.d.ts.map +0 -1
- package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
- package/src/components/ControlSection/ControlSection.tsx +0 -153
- package/src/components/Form/ArrayField.tsx +0 -125
- package/src/components/Form/Defaults.tsx +0 -205
- package/src/components/Form/FormActions.tsx +0 -54
- package/src/components/Form/FormContent.tsx +0 -322
- package/src/components/Form/FormContext.tsx +0 -110
- package/src/components/Form/Input.tsx +0 -54
- package/src/components/Form/RefField.tsx +0 -283
- package/src/components/Form/custom/GeoPointInput.tsx +0 -80
- package/src/components/Form/custom/index.ts +0 -7
- package/src/components/Form/factory.tsx +0 -31
- package/src/hooks/useForm.ts +0 -296
- package/src/hooks/useQueryRefOptions.ts +0 -62
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -12
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
6
|
+
import React, { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type AnyProperties } from '@dxos/echo/internal';
|
|
9
|
+
import { createJsonPath, type SchemaProperty } from '@dxos/effect';
|
|
10
|
+
|
|
11
|
+
import { type FormHandlerProps } from '../../hooks';
|
|
12
|
+
import { getFormProperties } from '../../util';
|
|
13
|
+
import { useFormValues } from './Form';
|
|
14
|
+
import { FormField, type FormFieldProps } from './FormField';
|
|
15
|
+
import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
|
|
16
|
+
import { DEFAULT_LAYOUT_NAME, FormLayout, FormLayoutAnnotation } from './Layout';
|
|
17
|
+
|
|
18
|
+
const FORM_FIELDSET_NAME = 'Form.FieldSet';
|
|
19
|
+
|
|
20
|
+
export type FormFieldSetProps<T extends AnyProperties> = {
|
|
21
|
+
label?: string;
|
|
22
|
+
sort?: string[];
|
|
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;
|
|
30
|
+
} & Pick<FormHandlerProps<T>, 'schema'> &
|
|
31
|
+
Pick<
|
|
32
|
+
FormFieldProps,
|
|
33
|
+
| 'path'
|
|
34
|
+
| 'autoFocus'
|
|
35
|
+
| 'readonly'
|
|
36
|
+
| 'layout'
|
|
37
|
+
| 'projection'
|
|
38
|
+
| 'fieldMap'
|
|
39
|
+
| 'fieldProvider'
|
|
40
|
+
| 'createTypename'
|
|
41
|
+
| 'createOptionLabel'
|
|
42
|
+
| 'createOptionIcon'
|
|
43
|
+
| 'createInitialValuePath'
|
|
44
|
+
| 'createFieldMap'
|
|
45
|
+
| 'db'
|
|
46
|
+
| 'useType'
|
|
47
|
+
| 'getOptions'
|
|
48
|
+
| 'onCreate'
|
|
49
|
+
>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Renders a set of form fields derived from a schema object.
|
|
53
|
+
*/
|
|
54
|
+
export const FormFieldSet = ({
|
|
55
|
+
label,
|
|
56
|
+
schema,
|
|
57
|
+
readonly,
|
|
58
|
+
path,
|
|
59
|
+
sort,
|
|
60
|
+
exclude,
|
|
61
|
+
projection,
|
|
62
|
+
layout,
|
|
63
|
+
layoutName = DEFAULT_LAYOUT_NAME,
|
|
64
|
+
...props
|
|
65
|
+
}: FormFieldSetProps<any>) => {
|
|
66
|
+
const values = useFormValues(FORM_FIELDSET_NAME, path);
|
|
67
|
+
const properties = useFormFieldSetProperties({ schema, values, exclude, sort, projection });
|
|
68
|
+
if ((readonly || layout === 'static') && values == null) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
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
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
|
|
95
|
+
{properties.map((property) => {
|
|
96
|
+
const name = property.name.toString();
|
|
97
|
+
return (
|
|
98
|
+
<FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
|
|
99
|
+
<FormField
|
|
100
|
+
type={property.type}
|
|
101
|
+
name={name}
|
|
102
|
+
path={[...(path ?? []), name]}
|
|
103
|
+
readonly={readonly}
|
|
104
|
+
layout={layout}
|
|
105
|
+
projection={projection}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
</FormFieldErrorBoundary>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
FormFieldSet.displayName = FORM_FIELDSET_NAME;
|
|
116
|
+
|
|
117
|
+
type UseFormFieldSetPropertiesParams = Pick<FormFieldSetProps<any>, 'schema' | 'exclude' | 'projection' | 'sort'> & {
|
|
118
|
+
values: AnyProperties | undefined;
|
|
119
|
+
sort?: string[];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolves ordered schema properties for a field set (projection order, exclude, or sort).
|
|
124
|
+
*/
|
|
125
|
+
const useFormFieldSetProperties = ({
|
|
126
|
+
schema,
|
|
127
|
+
exclude,
|
|
128
|
+
projection,
|
|
129
|
+
values,
|
|
130
|
+
sort,
|
|
131
|
+
}: UseFormFieldSetPropertiesParams): SchemaProperty[] => {
|
|
132
|
+
// TODO(burdon): Updates on every value change.
|
|
133
|
+
// Remove values dep if can remove from getSchemaProperties.
|
|
134
|
+
return useMemo(() => {
|
|
135
|
+
if (!schema) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
|
|
140
|
+
const schemaProps = getFormProperties(schema.ast);
|
|
141
|
+
const filteredProps = exclude ? exclude(schemaProps) : schemaProps;
|
|
142
|
+
|
|
143
|
+
// Use projection-based field management when view and projection are available.
|
|
144
|
+
if (projection) {
|
|
145
|
+
const fieldProjections = projection.getFieldProjections();
|
|
146
|
+
const hiddenProperties = new Set(projection.getHiddenProperties());
|
|
147
|
+
|
|
148
|
+
// Filter properties to only include visible ones and order by projection.
|
|
149
|
+
const visibleProps = filteredProps.filter((prop) => !hiddenProperties.has(prop.name.toString()));
|
|
150
|
+
const orderedProps: SchemaProperty[] = [];
|
|
151
|
+
|
|
152
|
+
// Add properties in projection field order.
|
|
153
|
+
for (const fieldProjection of fieldProjections) {
|
|
154
|
+
const fieldPath = String(fieldProjection.field.path);
|
|
155
|
+
const prop = visibleProps.find((prop) => prop.name === fieldPath);
|
|
156
|
+
if (prop) {
|
|
157
|
+
orderedProps.push(prop);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Add any remaining properties not in projection.
|
|
162
|
+
const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
|
|
163
|
+
const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
|
|
164
|
+
orderedProps.push(...remainingProps);
|
|
165
|
+
return orderedProps;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fallback to legacy filter/sort behavior.
|
|
169
|
+
return sort
|
|
170
|
+
? [...filteredProps].sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
|
|
171
|
+
: filteredProps;
|
|
172
|
+
}, [schema, values, exclude, sort, projection]);
|
|
173
|
+
};
|
|
@@ -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,339 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { xml } from '@codemirror/lang-xml';
|
|
6
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
7
|
+
import * as Schema from 'effect/Schema';
|
|
8
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { DXN, Format, Type } from '@dxos/echo';
|
|
11
|
+
import { withClientProvider } from '@dxos/react-client/testing';
|
|
12
|
+
import { Card, Tooltip, useThemeContext } from '@dxos/react-ui';
|
|
13
|
+
import { Editor } from '@dxos/react-ui-editor';
|
|
14
|
+
import { Syntax } from '@dxos/react-ui-syntax-highlighter';
|
|
15
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
16
|
+
import { createBasicExtensions, createThemeExtensions } from '@dxos/ui-editor';
|
|
17
|
+
import { mx } from '@dxos/ui-theme';
|
|
18
|
+
import { trim } from '@dxos/util';
|
|
19
|
+
|
|
20
|
+
import { translations } from '#translations';
|
|
21
|
+
|
|
22
|
+
import { TestLayout, TestPanel } from '../../testing';
|
|
23
|
+
import { Form, type FormRootProps, omitId } from '../Form';
|
|
24
|
+
import { FormLayoutAnnotation } from './annotation';
|
|
25
|
+
import { parseLayout } from './parser';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sample schema: a Flight booking carries airline, flight number, route, dates,
|
|
29
|
+
* cabin class. Without an annotation `<Form.FieldSet/>` renders one field per
|
|
30
|
+
* row (see the `Linear` story). The `FormLayoutAnnotation` on this schema
|
|
31
|
+
* arranges the same fields in a 2-column grid with selective spans.
|
|
32
|
+
*/
|
|
33
|
+
const Flight = Schema.Struct({
|
|
34
|
+
airline: Schema.optional(Schema.String.annotations({ title: 'Airline' })),
|
|
35
|
+
flightNumber: Schema.optional(Schema.String.annotations({ title: 'Flight #' })),
|
|
36
|
+
origin: Schema.optional(Schema.String.annotations({ title: 'From' })),
|
|
37
|
+
destination: Schema.optional(Schema.String.annotations({ title: 'To' })),
|
|
38
|
+
departAt: Schema.optional(Format.DateTime.annotations({ title: 'Depart' })),
|
|
39
|
+
arriveAt: Schema.optional(Format.DateTime.annotations({ title: 'Arrive' })),
|
|
40
|
+
cabin: Schema.optional(Schema.Literal('economy', 'premium', 'business', 'first').annotations({ title: 'Cabin' })),
|
|
41
|
+
notes: Schema.optional(Format.Text.annotations({ title: 'Notes' })),
|
|
42
|
+
}).pipe(Type.makeObject(DXN.make('com.example.type.flight', '0.1.0')));
|
|
43
|
+
|
|
44
|
+
export type Flight = Type.InstanceType<typeof Flight>;
|
|
45
|
+
|
|
46
|
+
type FlightValues = Omit<Type.InstanceType<typeof Flight>, 'id'>;
|
|
47
|
+
|
|
48
|
+
const flight = {
|
|
49
|
+
airline: 'Air France',
|
|
50
|
+
flightNumber: 'AF-1',
|
|
51
|
+
origin: 'JFK',
|
|
52
|
+
destination: 'CDG',
|
|
53
|
+
departAt: '2026-06-01T15:30:00.000Z',
|
|
54
|
+
arriveAt: '2026-06-02T09:30:00.000Z',
|
|
55
|
+
cabin: 'economy',
|
|
56
|
+
} satisfies Partial<FlightValues>;
|
|
57
|
+
|
|
58
|
+
const FLIGHT_LAYOUT = trim`
|
|
59
|
+
<grid cols="2">
|
|
60
|
+
<field name="airline"/>
|
|
61
|
+
<field name="flightNumber"/>
|
|
62
|
+
<field name="origin"/>
|
|
63
|
+
<field name="destination"/>
|
|
64
|
+
<field name="departAt"/>
|
|
65
|
+
<field name="arriveAt"/>
|
|
66
|
+
<field name="cabin" span="2"/>
|
|
67
|
+
<field name="notes" span="2"/>
|
|
68
|
+
</grid>
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
/** Compact variant — single column, only the headline fields. */
|
|
72
|
+
const FLIGHT_LAYOUT_COMPACT = trim`
|
|
73
|
+
<grid cols="1">
|
|
74
|
+
<field name="airline"/>
|
|
75
|
+
<field name="flightNumber"/>
|
|
76
|
+
<field name="departAt"/>
|
|
77
|
+
</grid>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Same schema annotated with two named layouts. `Form.Layout name="…"`
|
|
82
|
+
* (or `Form.FieldSet layoutName="…"`) picks the variant; without a name
|
|
83
|
+
* the `'default'` entry is used.
|
|
84
|
+
*/
|
|
85
|
+
const AnnotatedFlight = Type.getSchema(Flight)
|
|
86
|
+
.annotations({})
|
|
87
|
+
.pipe(FormLayoutAnnotation.set({ default: FLIGHT_LAYOUT, compact: FLIGHT_LAYOUT_COMPACT }));
|
|
88
|
+
|
|
89
|
+
type StoryProps = {
|
|
90
|
+
schema: Schema.Schema<any>;
|
|
91
|
+
template?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const DefaultStory = ({ schema, template }: StoryProps) => {
|
|
95
|
+
const [values, setValues] = useState<Partial<FlightValues>>(flight);
|
|
96
|
+
|
|
97
|
+
const handleSave = useCallback<NonNullable<FormRootProps<any>['onSave']>>((next) => {
|
|
98
|
+
setValues(next as Partial<FlightValues>);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Tooltip.Provider>
|
|
103
|
+
<TestLayout json={{ values }}>
|
|
104
|
+
<Form.Root schema={schema} defaultValues={values} onSave={handleSave} autoSave>
|
|
105
|
+
<Form.Viewport>
|
|
106
|
+
<Form.Content>
|
|
107
|
+
{template !== undefined ? <Form.Layout schema={schema} template={template} /> : <Form.FieldSet />}
|
|
108
|
+
</Form.Content>
|
|
109
|
+
</Form.Viewport>
|
|
110
|
+
</Form.Root>
|
|
111
|
+
</TestLayout>
|
|
112
|
+
</Tooltip.Provider>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const meta = {
|
|
117
|
+
title: 'ui/react-ui-form/FormLayout',
|
|
118
|
+
render: DefaultStory,
|
|
119
|
+
decorators: [
|
|
120
|
+
withTheme(),
|
|
121
|
+
withLayout({ layout: 'fullscreen' }),
|
|
122
|
+
withClientProvider({ createIdentity: true, createSpace: true }),
|
|
123
|
+
],
|
|
124
|
+
parameters: {
|
|
125
|
+
layout: 'fullscreen',
|
|
126
|
+
translations,
|
|
127
|
+
},
|
|
128
|
+
} satisfies Meta<StoryProps>;
|
|
129
|
+
|
|
130
|
+
export default meta;
|
|
131
|
+
|
|
132
|
+
type Story = StoryObj<StoryProps>;
|
|
133
|
+
|
|
134
|
+
/** Baseline: no annotation, no override — `Form.FieldSet` renders linearly (one field per row). */
|
|
135
|
+
export const Linear: Story = {
|
|
136
|
+
args: {
|
|
137
|
+
schema: omitId(Type.getSchema(Flight)),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** `Form.Layout` invoked directly with a `template` prop, bypassing any annotation. */
|
|
142
|
+
export const TemplateProp: Story = {
|
|
143
|
+
args: {
|
|
144
|
+
schema: omitId(Type.getSchema(Flight)),
|
|
145
|
+
template: FLIGHT_LAYOUT,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Schema carries `FormLayoutAnnotation`; `Form.FieldSet` auto-detects and delegates. */
|
|
150
|
+
export const SchemaAnnotation: Story = {
|
|
151
|
+
args: {
|
|
152
|
+
schema: omitId(AnnotatedFlight),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Same annotated schema, but the story switches between the two named
|
|
158
|
+
* layouts (`default` and `compact`) via a radio above the form.
|
|
159
|
+
*/
|
|
160
|
+
const NamedAnnotationStory = () => {
|
|
161
|
+
const schema = useMemo(() => omitId(AnnotatedFlight) as unknown as Schema.Schema<any>, []);
|
|
162
|
+
const [layoutName, setLayoutName] = useState<'default' | 'compact'>('default');
|
|
163
|
+
const [values, setValues] = useState<Partial<FlightValues>>(flight);
|
|
164
|
+
|
|
165
|
+
const handleSave = useCallback<NonNullable<FormRootProps<any>['onSave']>>((next) => {
|
|
166
|
+
setValues(next as Flight);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Tooltip.Provider>
|
|
171
|
+
<TestLayout json={{ layoutName, values }}>
|
|
172
|
+
<div className='flex flex-col gap-2'>
|
|
173
|
+
<div className='flex gap-2 text-sm'>
|
|
174
|
+
{(['default', 'compact'] as const).map((name) => (
|
|
175
|
+
<label key={name} className='flex items-center gap-1'>
|
|
176
|
+
<input type='radio' name='layout' checked={layoutName === name} onChange={() => setLayoutName(name)} />
|
|
177
|
+
{name}
|
|
178
|
+
</label>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
<Form.Root schema={schema} defaultValues={values} onSave={handleSave} autoSave>
|
|
182
|
+
<Form.Viewport>
|
|
183
|
+
<Form.Content>
|
|
184
|
+
<Form.FieldSet layoutName={layoutName} />
|
|
185
|
+
</Form.Content>
|
|
186
|
+
</Form.Viewport>
|
|
187
|
+
</Form.Root>
|
|
188
|
+
</div>
|
|
189
|
+
</TestLayout>
|
|
190
|
+
</Tooltip.Provider>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const NamedAnnotation: Story = {
|
|
195
|
+
render: () => <NamedAnnotationStory />,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Live playground: the layout template lives in a code editor on the right.
|
|
200
|
+
* The form on the left re-renders as you type. If the template fails to
|
|
201
|
+
* parse, the previous valid layout stays on screen and an error banner
|
|
202
|
+
* surfaces under the editor.
|
|
203
|
+
*/
|
|
204
|
+
type PlaygroundStoryProps = {
|
|
205
|
+
/** Wrap the rendered form in `Card.Root` / `Card.Body` chrome. */
|
|
206
|
+
card?: boolean;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const PlaygroundStory = ({ card = false }: PlaygroundStoryProps) => {
|
|
210
|
+
const schema = useMemo(() => omitId(Type.getSchema(Flight)) as unknown as Schema.Schema<any>, []);
|
|
211
|
+
const [template, setTemplate] = useState(FLIGHT_LAYOUT);
|
|
212
|
+
const [lastValid, setLastValid] = useState(FLIGHT_LAYOUT);
|
|
213
|
+
const [error, setError] = useState<string | undefined>();
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
try {
|
|
217
|
+
parseLayout(template);
|
|
218
|
+
setLastValid(template);
|
|
219
|
+
setError(undefined);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
222
|
+
}
|
|
223
|
+
}, [template]);
|
|
224
|
+
|
|
225
|
+
const [values, setValues] = useState<Partial<FlightValues>>(flight);
|
|
226
|
+
|
|
227
|
+
const handleSave = useCallback<NonNullable<FormRootProps<any>['onSave']>>((next) => {
|
|
228
|
+
setValues(next as Partial<FlightValues>);
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
231
|
+
const { themeMode } = useThemeContext();
|
|
232
|
+
const extensions = useMemo(
|
|
233
|
+
() => [
|
|
234
|
+
xml(),
|
|
235
|
+
createBasicExtensions({ placeholder: 'Edit layout DSL…' }),
|
|
236
|
+
createThemeExtensions({ themeMode, syntaxHighlighting: true }),
|
|
237
|
+
],
|
|
238
|
+
[themeMode],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const renderForm = (readonly = false) => (
|
|
242
|
+
<Form.Root
|
|
243
|
+
schema={schema}
|
|
244
|
+
defaultValues={values}
|
|
245
|
+
layout={readonly ? 'static' : 'full'}
|
|
246
|
+
readonly={readonly}
|
|
247
|
+
autoSave
|
|
248
|
+
onSave={handleSave}
|
|
249
|
+
>
|
|
250
|
+
<Form.Viewport>
|
|
251
|
+
<Form.Content>
|
|
252
|
+
<Form.Layout schema={schema} template={lastValid} />
|
|
253
|
+
</Form.Content>
|
|
254
|
+
</Form.Viewport>
|
|
255
|
+
</Form.Root>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<Tooltip.Provider>
|
|
260
|
+
<div
|
|
261
|
+
className={mx(
|
|
262
|
+
'dx-container grid grid-rows-1 p-4 gap-4 overflow-hidden',
|
|
263
|
+
card ? 'grid-cols-[var(--spacing-card-min-width)_var(--spacing-card-min-width)_1fr]' : 'grid-cols-2',
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{card ? (
|
|
267
|
+
<>
|
|
268
|
+
<div>
|
|
269
|
+
<Card.Root fullWidth>
|
|
270
|
+
<Card.Header>
|
|
271
|
+
<Card.DragHandle />
|
|
272
|
+
<Card.Title>Read-only</Card.Title>
|
|
273
|
+
<Card.ActionIconButton action='close' onClick={() => console.log('close')} />
|
|
274
|
+
</Card.Header>
|
|
275
|
+
<Card.Body>{renderForm(true)}</Card.Body>
|
|
276
|
+
</Card.Root>
|
|
277
|
+
</div>
|
|
278
|
+
<div>
|
|
279
|
+
<Card.Root fullWidth>
|
|
280
|
+
<Card.Header>
|
|
281
|
+
<Card.DragHandle />
|
|
282
|
+
<Card.Title>Editable</Card.Title>
|
|
283
|
+
<Card.ActionIconButton action='close' onClick={() => console.log('close')} />
|
|
284
|
+
</Card.Header>
|
|
285
|
+
<Card.Body>{renderForm(false)}</Card.Body>
|
|
286
|
+
</Card.Root>
|
|
287
|
+
</div>
|
|
288
|
+
</>
|
|
289
|
+
) : (
|
|
290
|
+
<TestPanel>{renderForm(false)}</TestPanel>
|
|
291
|
+
)}
|
|
292
|
+
<div className='grid grid-rows-3 gap-4 overflow-hidden'>
|
|
293
|
+
<TestPanel>
|
|
294
|
+
<div className='flex flex-col h-full overflow-hidden'>
|
|
295
|
+
<Editor.Root>
|
|
296
|
+
<Editor.View
|
|
297
|
+
classNames='flex-1 overflow-auto font-mono text-sm p-2'
|
|
298
|
+
extensions={extensions}
|
|
299
|
+
value={template}
|
|
300
|
+
onChange={setTemplate}
|
|
301
|
+
/>
|
|
302
|
+
</Editor.Root>
|
|
303
|
+
{error && <div className='border-t border-separator p-2 text-sm text-error-text font-mono'>{error}</div>}
|
|
304
|
+
</div>
|
|
305
|
+
</TestPanel>
|
|
306
|
+
<TestPanel>
|
|
307
|
+
<Syntax.Root data={schema.ast}>
|
|
308
|
+
<Syntax.Content>
|
|
309
|
+
<Syntax.Filter />
|
|
310
|
+
<Syntax.Viewport>
|
|
311
|
+
<Syntax.Code testId='schema' classNames='text-sm' />
|
|
312
|
+
</Syntax.Viewport>
|
|
313
|
+
</Syntax.Content>
|
|
314
|
+
</Syntax.Root>
|
|
315
|
+
</TestPanel>
|
|
316
|
+
<TestPanel>
|
|
317
|
+
<Syntax.Root data={values}>
|
|
318
|
+
<Syntax.Content>
|
|
319
|
+
<Syntax.Filter />
|
|
320
|
+
<Syntax.Viewport>
|
|
321
|
+
<Syntax.Code testId='data' classNames='text-sm' />
|
|
322
|
+
</Syntax.Viewport>
|
|
323
|
+
</Syntax.Content>
|
|
324
|
+
</Syntax.Root>
|
|
325
|
+
</TestPanel>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</Tooltip.Provider>
|
|
329
|
+
);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const FormPlayground: Story = {
|
|
333
|
+
render: () => <PlaygroundStory />,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Same playground, but the form is rendered inside a `Card.Root` / `Card.Body`. */
|
|
337
|
+
export const CardPlayground: Story = {
|
|
338
|
+
render: () => <PlaygroundStory card />,
|
|
339
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
6
|
+
import type * as Schema from 'effect/Schema';
|
|
7
|
+
import React, { Fragment, useMemo } from 'react';
|
|
8
|
+
|
|
9
|
+
import { type AnyProperties } from '@dxos/echo/internal';
|
|
10
|
+
import { type SchemaProperty } from '@dxos/effect';
|
|
11
|
+
|
|
12
|
+
import { getFormProperties } from '../../../util';
|
|
13
|
+
import { FormField, type FormFieldProps } from '../FormField';
|
|
14
|
+
import { FormFieldErrorBoundary } from '../FormFieldComponent';
|
|
15
|
+
import { DEFAULT_LAYOUT_NAME, FormLayoutAnnotation } from './annotation';
|
|
16
|
+
import { LayoutParseError, type LayoutNode, parseLayout } from './parser';
|
|
17
|
+
|
|
18
|
+
const FORM_LAYOUT_NAME = 'Form.Layout';
|
|
19
|
+
|
|
20
|
+
export type FormLayoutProps = {
|
|
21
|
+
/**
|
|
22
|
+
* Template string in the FormLayout DSL (`<grid cols=…><field name=…/></grid>`).
|
|
23
|
+
* If omitted, the layout is read from the schema's `FormLayoutAnnotation` —
|
|
24
|
+
* the entry named by `name` (default: `'default'`).
|
|
25
|
+
*/
|
|
26
|
+
template?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Picks a named layout out of `FormLayoutAnnotation`'s map when no
|
|
29
|
+
* explicit `template` is supplied. Defaults to `'default'`.
|
|
30
|
+
*/
|
|
31
|
+
name?: string;
|
|
32
|
+
} & Pick<FormFieldSetSubset, 'schema' | 'path' | 'readonly' | 'layout' | 'projection' | 'fieldMap' | 'fieldProvider'>;
|
|
33
|
+
|
|
34
|
+
type FormFieldSetSubset = Pick<
|
|
35
|
+
FormFieldProps,
|
|
36
|
+
'path' | 'readonly' | 'layout' | 'projection' | 'fieldMap' | 'fieldProvider'
|
|
37
|
+
> & {
|
|
38
|
+
schema: Schema.Schema<AnyProperties>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lays out schema fields according to a `FormLayout` DSL template. The template
|
|
43
|
+
* is either passed via the `template` prop or read from the schema's
|
|
44
|
+
* `FormLayoutAnnotation`. Fields not referenced in the template are hidden —
|
|
45
|
+
* the template controls exactly what renders.
|
|
46
|
+
*/
|
|
47
|
+
export const FormLayout = ({
|
|
48
|
+
schema,
|
|
49
|
+
template,
|
|
50
|
+
name = DEFAULT_LAYOUT_NAME,
|
|
51
|
+
path,
|
|
52
|
+
readonly,
|
|
53
|
+
layout,
|
|
54
|
+
projection,
|
|
55
|
+
...props
|
|
56
|
+
}: FormLayoutProps) => {
|
|
57
|
+
const annotated = Option.getOrUndefined(FormLayoutAnnotation.get(schema));
|
|
58
|
+
const source = template ?? annotated?.[name];
|
|
59
|
+
if (source === undefined) {
|
|
60
|
+
throw new LayoutParseError(
|
|
61
|
+
annotated
|
|
62
|
+
? `no layout named "${name}" on schema (available: ${Object.keys(annotated).join(', ') || '∅'})`
|
|
63
|
+
: `no template provided and schema has no FormLayoutAnnotation`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tree = useMemo(() => parseLayout(source), [source]);
|
|
68
|
+
const properties = useMemo(() => {
|
|
69
|
+
const map = new Map<string, SchemaProperty>();
|
|
70
|
+
for (const property of getFormProperties(schema.ast)) {
|
|
71
|
+
map.set(String(property.name), property);
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}, [schema]);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<RenderNode
|
|
78
|
+
node={tree}
|
|
79
|
+
properties={properties}
|
|
80
|
+
basePath={path ?? []}
|
|
81
|
+
readonly={readonly}
|
|
82
|
+
layout={layout}
|
|
83
|
+
projection={projection}
|
|
84
|
+
{...props}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
FormLayout.displayName = FORM_LAYOUT_NAME;
|
|
90
|
+
|
|
91
|
+
type RenderNodeProps = Omit<FormLayoutProps, 'schema' | 'template' | 'path'> & {
|
|
92
|
+
node: LayoutNode;
|
|
93
|
+
properties: Map<string, SchemaProperty>;
|
|
94
|
+
basePath: (string | number)[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const RenderNode = ({ node, properties, basePath, ...props }: RenderNodeProps) => {
|
|
98
|
+
if (node.kind === 'grid') {
|
|
99
|
+
return (
|
|
100
|
+
<div className='grid gap-x-form-gap' style={{ gridTemplateColumns: `repeat(${node.cols}, minmax(0, 1fr))` }}>
|
|
101
|
+
{node.children.map((child, index) => (
|
|
102
|
+
<Fragment key={index}>
|
|
103
|
+
<RenderNode node={child} properties={properties} basePath={basePath} {...props} />
|
|
104
|
+
</Fragment>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const property = properties.get(node.name);
|
|
111
|
+
if (!property) {
|
|
112
|
+
throw new LayoutParseError(`field "${node.name}" not found on schema`);
|
|
113
|
+
}
|
|
114
|
+
const path = [...basePath, node.name];
|
|
115
|
+
const span = node.span ? { gridColumn: `span ${node.span} / span ${node.span}` } : undefined;
|
|
116
|
+
return (
|
|
117
|
+
<div className='min-w-0' style={span}>
|
|
118
|
+
<FormFieldErrorBoundary path={path}>
|
|
119
|
+
<FormField type={property.type} name={node.name} path={path} {...props} />
|
|
120
|
+
</FormFieldErrorBoundary>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createAnnotationHelper } from '@dxos/echo/internal';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Annotation carrying one or more named `FormLayout` DSL templates that
|
|
9
|
+
* control how `Form.FieldSet` / `Form.Layout` arrange a schema's fields.
|
|
10
|
+
* Callers select a variant via the `name` prop; the implicit name is
|
|
11
|
+
* `DEFAULT_LAYOUT_NAME` (`'default'`).
|
|
12
|
+
*
|
|
13
|
+
* Templates use a minimal XML grammar — see `parser.ts`. Example:
|
|
14
|
+
*
|
|
15
|
+
* FormLayoutAnnotation.set({
|
|
16
|
+
* default: `
|
|
17
|
+
* <grid cols="2">
|
|
18
|
+
* <field name="origin"/>
|
|
19
|
+
* <field name="destination"/>
|
|
20
|
+
* <field name="provider" span="2"/>
|
|
21
|
+
* </grid>
|
|
22
|
+
* `,
|
|
23
|
+
* card: `
|
|
24
|
+
* <grid cols="1">
|
|
25
|
+
* <field name="provider"/>
|
|
26
|
+
* <field name="number"/>
|
|
27
|
+
* </grid>
|
|
28
|
+
* `,
|
|
29
|
+
* })
|
|
30
|
+
*/
|
|
31
|
+
export const FormLayoutAnnotationId = Symbol.for('@dxos/react-ui-form/annotation/Layout');
|
|
32
|
+
|
|
33
|
+
export type FormLayoutMap = Record<string, string>;
|
|
34
|
+
|
|
35
|
+
export const FormLayoutAnnotation = createAnnotationHelper<FormLayoutMap>(FormLayoutAnnotationId);
|
|
36
|
+
|
|
37
|
+
/** Name used when no explicit variant is requested. */
|
|
38
|
+
export const DEFAULT_LAYOUT_NAME = 'default';
|