@dxos/react-ui-form 0.7.1-staging.599df14
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 +8 -0
- package/README.md +4 -0
- package/dist/lib/browser/index.mjs +1008 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node/index.cjs +1028 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +1010 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +16 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +9 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/FieldEditor/index.d.ts +2 -0
- package/dist/types/src/components/FieldEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/Custom.d.ts +4 -0
- package/dist/types/src/components/Form/Custom.d.ts.map +1 -0
- package/dist/types/src/components/Form/Defaults.d.ts +7 -0
- package/dist/types/src/components/Form/Defaults.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.d.ts +26 -0
- package/dist/types/src/components/Form/Form.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.stories.d.ts +38 -0
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Input.d.ts +29 -0
- package/dist/types/src/components/Form/Input.d.ts.map +1 -0
- package/dist/types/src/components/Form/factory.d.ts +8 -0
- package/dist/types/src/components/Form/factory.d.ts.map +1 -0
- package/dist/types/src/components/Form/index.d.ts +5 -0
- package/dist/types/src/components/Form/index.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/Test.stories.d.ts +10 -0
- package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +18 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +8 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/index.d.ts +2 -0
- package/dist/types/src/components/ViewEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/deprecated/Form.d.ts +15 -0
- package/dist/types/src/components/deprecated/Form.d.ts.map +1 -0
- package/dist/types/src/components/deprecated/Form.stories.d.ts +13 -0
- package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -0
- package/dist/types/src/components/deprecated/FormInput.d.ts +11 -0
- package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -0
- package/dist/types/src/components/deprecated/index.d.ts +2 -0
- package/dist/types/src/components/deprecated/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/components/testing.d.ts +7 -0
- package/dist/types/src/components/testing.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useForm.d.ts +58 -0
- package/dist/types/src/hooks/useForm.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +46 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/util/field.d.ts +22 -0
- package/dist/types/src/util/field.d.ts.map +1 -0
- package/dist/types/src/util/formatting.d.ts +15 -0
- package/dist/types/src/util/formatting.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +3 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/package.json +76 -0
- package/src/components/FieldEditor/FieldEditor.stories.tsx +56 -0
- package/src/components/FieldEditor/FieldEditor.tsx +167 -0
- package/src/components/FieldEditor/index.ts +5 -0
- package/src/components/Form/Custom.tsx +65 -0
- package/src/components/Form/Defaults.tsx +130 -0
- package/src/components/Form/Form.stories.tsx +137 -0
- package/src/components/Form/Form.tsx +176 -0
- package/src/components/Form/Input.tsx +56 -0
- package/src/components/Form/factory.tsx +32 -0
- package/src/components/Form/index.ts +8 -0
- package/src/components/ViewEditor/Test.stories.tsx +45 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +73 -0
- package/src/components/ViewEditor/ViewEditor.tsx +158 -0
- package/src/components/ViewEditor/index.ts +5 -0
- package/src/components/deprecated/Form.stories.tsx +62 -0
- package/src/components/deprecated/Form.tsx +119 -0
- package/src/components/deprecated/FormInput.tsx +58 -0
- package/src/components/deprecated/index.ts +5 -0
- package/src/components/index.ts +9 -0
- package/src/components/testing.tsx +24 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useForm.ts +229 -0
- package/src/index.ts +11 -0
- package/src/translations.ts +59 -0
- package/src/util/field.ts +137 -0
- package/src/util/formatting.ts +132 -0
- package/src/util/index.ts +6 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { Input, Select } from '@dxos/react-ui';
|
|
8
|
+
|
|
9
|
+
import { type InputProps, InputHeader } from './Input';
|
|
10
|
+
|
|
11
|
+
export const TextInput = <T extends object>({
|
|
12
|
+
property,
|
|
13
|
+
type,
|
|
14
|
+
label,
|
|
15
|
+
disabled,
|
|
16
|
+
placeholder,
|
|
17
|
+
getStatus,
|
|
18
|
+
getValue,
|
|
19
|
+
onValueChange,
|
|
20
|
+
onBlur,
|
|
21
|
+
}: InputProps<T>) => {
|
|
22
|
+
const { status, error } = getStatus?.(property);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Input.Root validationValence={status}>
|
|
26
|
+
<InputHeader error={error}>
|
|
27
|
+
<Input.Label>{label}</Input.Label>
|
|
28
|
+
</InputHeader>
|
|
29
|
+
<Input.TextInput
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
placeholder={placeholder}
|
|
32
|
+
value={getValue(property) ?? ''}
|
|
33
|
+
onChange={(event) => onValueChange(property, type, event.target.value)}
|
|
34
|
+
onBlur={onBlur}
|
|
35
|
+
/>
|
|
36
|
+
</Input.Root>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const NumberInput = <T extends object>({
|
|
41
|
+
property,
|
|
42
|
+
type,
|
|
43
|
+
label,
|
|
44
|
+
disabled,
|
|
45
|
+
placeholder,
|
|
46
|
+
getStatus,
|
|
47
|
+
getValue,
|
|
48
|
+
onValueChange,
|
|
49
|
+
onBlur,
|
|
50
|
+
}: InputProps<T>) => {
|
|
51
|
+
const { status, error } = getStatus?.(property);
|
|
52
|
+
|
|
53
|
+
// TODO(burdon): Only show stepper if bounded integer.
|
|
54
|
+
return (
|
|
55
|
+
<Input.Root validationValence={status}>
|
|
56
|
+
<InputHeader error={error}>
|
|
57
|
+
<Input.Label>{label}</Input.Label>
|
|
58
|
+
</InputHeader>
|
|
59
|
+
<Input.TextInput
|
|
60
|
+
type='number'
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
value={getValue(property) ?? 0}
|
|
64
|
+
onChange={(event) => onValueChange(property, type, event.target.value)}
|
|
65
|
+
onBlur={onBlur}
|
|
66
|
+
/>
|
|
67
|
+
</Input.Root>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const BooleanInput = <T extends object>({
|
|
72
|
+
property,
|
|
73
|
+
type,
|
|
74
|
+
label,
|
|
75
|
+
getStatus,
|
|
76
|
+
getValue,
|
|
77
|
+
onValueChange,
|
|
78
|
+
}: InputProps<T>) => {
|
|
79
|
+
const { status, error } = getStatus?.(property);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Input.Root validationValence={status}>
|
|
83
|
+
<InputHeader error={error}>
|
|
84
|
+
<Input.Label>{label}</Input.Label>
|
|
85
|
+
</InputHeader>
|
|
86
|
+
<Input.Switch
|
|
87
|
+
checked={getValue<boolean>(property)}
|
|
88
|
+
onCheckedChange={(value) => onValueChange(property, type, value)}
|
|
89
|
+
/>
|
|
90
|
+
</Input.Root>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const SelectInput = <T extends object>({
|
|
95
|
+
property,
|
|
96
|
+
type,
|
|
97
|
+
label,
|
|
98
|
+
disabled,
|
|
99
|
+
placeholder,
|
|
100
|
+
options,
|
|
101
|
+
getStatus,
|
|
102
|
+
getValue,
|
|
103
|
+
onValueChange,
|
|
104
|
+
}: InputProps<T>) => {
|
|
105
|
+
const { status, error } = getStatus?.(property);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Input.Root validationValence={status}>
|
|
109
|
+
<InputHeader error={error}>
|
|
110
|
+
<Input.Label>{label}</Input.Label>
|
|
111
|
+
</InputHeader>
|
|
112
|
+
<Select.Root value={getValue(property)} onValueChange={(value) => onValueChange(property, type, value)}>
|
|
113
|
+
{/* TODO(burdon): Placeholder not working? */}
|
|
114
|
+
<Select.TriggerButton classNames='is-full' disabled={disabled} placeholder={placeholder} />
|
|
115
|
+
<Select.Portal>
|
|
116
|
+
<Select.Content>
|
|
117
|
+
<Select.Viewport>
|
|
118
|
+
{options?.map(({ value, label }) => (
|
|
119
|
+
// NOTE: Numeric values are converted to and from strings.
|
|
120
|
+
<Select.Option key={String(value)} value={String(value)}>
|
|
121
|
+
{label ?? String(value)}
|
|
122
|
+
</Select.Option>
|
|
123
|
+
))}
|
|
124
|
+
</Select.Viewport>
|
|
125
|
+
</Select.Content>
|
|
126
|
+
</Select.Portal>
|
|
127
|
+
</Select.Root>
|
|
128
|
+
</Input.Root>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import { type Meta, type StoryObj } from '@storybook/react';
|
|
8
|
+
import React, { useCallback, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { AST, Format, S } from '@dxos/echo-schema';
|
|
11
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
12
|
+
|
|
13
|
+
import { SelectInput } from './Defaults';
|
|
14
|
+
import { Form, type FormProps } from './Form';
|
|
15
|
+
import translations from '../../translations';
|
|
16
|
+
import { TestLayout, TestPanel } from '../testing';
|
|
17
|
+
|
|
18
|
+
// TODO(burdon): Translations?
|
|
19
|
+
const TestSchema = S.Struct({
|
|
20
|
+
name: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'Name' })),
|
|
21
|
+
active: S.optional(S.Boolean.annotations({ [AST.TitleAnnotationId]: 'Active' })),
|
|
22
|
+
rank: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Rank' })),
|
|
23
|
+
website: S.optional(Format.URL.annotations({ [AST.TitleAnnotationId]: 'Website' })),
|
|
24
|
+
address: S.optional(
|
|
25
|
+
S.Struct({
|
|
26
|
+
street: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'Street' })),
|
|
27
|
+
city: S.optional(S.String.annotations({ [AST.TitleAnnotationId]: 'City' })),
|
|
28
|
+
zip: S.optional(S.String.pipe(S.pattern(/^\d{5}(-\d{4})?$/)).annotations({ [AST.TitleAnnotationId]: 'ZIP' })),
|
|
29
|
+
location: S.optional(Format.LatLng.annotations({ [AST.TitleAnnotationId]: 'Location' })),
|
|
30
|
+
}).annotations({ [AST.TitleAnnotationId]: 'Address' }),
|
|
31
|
+
),
|
|
32
|
+
}).pipe(S.mutable);
|
|
33
|
+
|
|
34
|
+
type TestType = S.Schema.Type<typeof TestSchema>;
|
|
35
|
+
|
|
36
|
+
type StoryProps = FormProps<TestType>;
|
|
37
|
+
|
|
38
|
+
const DefaultStory = ({ values: initialValues }: StoryProps) => {
|
|
39
|
+
const [values, setValues] = useState(initialValues);
|
|
40
|
+
const handleSubmit = useCallback<NonNullable<FormProps<TestType>['onSubmit']>>((values) => {
|
|
41
|
+
setValues(values);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<TestLayout json={{ values, schema: TestSchema.ast.toJSON() }}>
|
|
46
|
+
<TestPanel>
|
|
47
|
+
<Form<TestType> schema={TestSchema} values={values} onSubmit={handleSubmit} />
|
|
48
|
+
</TestPanel>
|
|
49
|
+
</TestLayout>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const meta: Meta<StoryProps> = {
|
|
54
|
+
title: 'ui/react-ui-data/Form',
|
|
55
|
+
component: Form,
|
|
56
|
+
render: DefaultStory,
|
|
57
|
+
decorators: [withLayout({ fullscreen: true, tooltips: true }), withTheme],
|
|
58
|
+
parameters: {
|
|
59
|
+
translations,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default meta;
|
|
64
|
+
|
|
65
|
+
type Story = StoryObj<StoryProps>;
|
|
66
|
+
|
|
67
|
+
export const Default: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
values: {
|
|
70
|
+
name: 'DXOS',
|
|
71
|
+
active: true,
|
|
72
|
+
address: {
|
|
73
|
+
zip: '11205',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const ShapeSchema = S.Struct({
|
|
80
|
+
shape: S.optional(
|
|
81
|
+
S.Union(
|
|
82
|
+
S.Struct({
|
|
83
|
+
type: S.Literal('circle').annotations({ [AST.TitleAnnotationId]: 'Type' }),
|
|
84
|
+
radius: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Radius' })),
|
|
85
|
+
}),
|
|
86
|
+
S.Struct({
|
|
87
|
+
type: S.Literal('square').annotations({ [AST.TitleAnnotationId]: 'Type' }),
|
|
88
|
+
size: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Size' })),
|
|
89
|
+
}),
|
|
90
|
+
).annotations({ [AST.TitleAnnotationId]: 'Shape' }),
|
|
91
|
+
),
|
|
92
|
+
}).pipe(S.mutable);
|
|
93
|
+
|
|
94
|
+
type ShapeType = S.Schema.Type<typeof ShapeSchema>;
|
|
95
|
+
type DiscriminatedUnionStoryProps = FormProps<ShapeType>;
|
|
96
|
+
|
|
97
|
+
const DiscriminatedUnionStory = ({ values: initialValues }: DiscriminatedUnionStoryProps) => {
|
|
98
|
+
const [values, setValues] = useState(initialValues);
|
|
99
|
+
const handleSubmit = useCallback<NonNullable<FormProps<ShapeType>['onSubmit']>>((values) => {
|
|
100
|
+
setValues(values);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<TestLayout json={{ values, schema: ShapeSchema.ast.toJSON() }}>
|
|
105
|
+
<TestPanel>
|
|
106
|
+
<Form<ShapeType>
|
|
107
|
+
schema={ShapeSchema}
|
|
108
|
+
values={values}
|
|
109
|
+
onSubmit={handleSubmit}
|
|
110
|
+
Custom={{
|
|
111
|
+
['shape.type' as const]: (props) => (
|
|
112
|
+
<SelectInput<ShapeType>
|
|
113
|
+
{...props}
|
|
114
|
+
options={['circle', 'square'].map((value) => ({
|
|
115
|
+
value,
|
|
116
|
+
label: value,
|
|
117
|
+
}))}
|
|
118
|
+
/>
|
|
119
|
+
),
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
</TestPanel>
|
|
123
|
+
</TestLayout>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const DiscriminatedShape: StoryObj<DiscriminatedUnionStoryProps> = {
|
|
128
|
+
render: DiscriminatedUnionStory,
|
|
129
|
+
args: {
|
|
130
|
+
values: {
|
|
131
|
+
shape: {
|
|
132
|
+
type: 'circle',
|
|
133
|
+
radius: 5,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { AST, S } from '@dxos/echo-schema';
|
|
8
|
+
import { findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
|
|
9
|
+
import { log } from '@dxos/log';
|
|
10
|
+
import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
|
|
11
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
12
|
+
import { getSchemaProperties, type PropertyKey, type SchemaProperty } from '@dxos/schema';
|
|
13
|
+
import { isNotFalsy } from '@dxos/util';
|
|
14
|
+
|
|
15
|
+
import { type InputComponent } from './Input';
|
|
16
|
+
import { getInputComponent } from './factory';
|
|
17
|
+
import { type FormOptions, useForm } from '../../hooks';
|
|
18
|
+
import { translationKey } from '../../translations';
|
|
19
|
+
|
|
20
|
+
// TODO(burdon): Rename package react-ui-form; delete react-ui-card.
|
|
21
|
+
|
|
22
|
+
const padding = 'px-2';
|
|
23
|
+
|
|
24
|
+
export type PropsFilter<T extends Object> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
|
|
25
|
+
|
|
26
|
+
export type FormProps<T extends object> = ThemedClassName<
|
|
27
|
+
{
|
|
28
|
+
values: T;
|
|
29
|
+
/** Path to the current object from the root. Used with nested forms. */
|
|
30
|
+
path?: string[];
|
|
31
|
+
|
|
32
|
+
// TODO(burdon): Autofocus first input.
|
|
33
|
+
autoFocus?: boolean;
|
|
34
|
+
readonly?: boolean;
|
|
35
|
+
// TODO(burdon): Change to JsonPath includes/excludes.
|
|
36
|
+
filter?: PropsFilter<T>;
|
|
37
|
+
sort?: PropertyKey<T>[];
|
|
38
|
+
autoSave?: boolean;
|
|
39
|
+
onCancel?: () => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map of custom renderers for specific properties.
|
|
43
|
+
*/
|
|
44
|
+
Custom?: Partial<Record<string, InputComponent<T>>>;
|
|
45
|
+
} & Pick<FormOptions<T>, 'schema' | 'onValuesChanged' | 'onValidate' | 'onSubmit'>
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* General purpose form component that displays field controls based on the given schema.
|
|
50
|
+
*/
|
|
51
|
+
export const Form = <T extends object = {}>({
|
|
52
|
+
classNames,
|
|
53
|
+
schema,
|
|
54
|
+
values: initialValues,
|
|
55
|
+
path = [],
|
|
56
|
+
readonly,
|
|
57
|
+
filter,
|
|
58
|
+
sort,
|
|
59
|
+
autoSave,
|
|
60
|
+
onValuesChanged,
|
|
61
|
+
onValidate,
|
|
62
|
+
onSubmit,
|
|
63
|
+
onCancel,
|
|
64
|
+
Custom,
|
|
65
|
+
}: FormProps<T>) => {
|
|
66
|
+
const { t } = useTranslation(translationKey);
|
|
67
|
+
const onValid = useMemo(() => (autoSave ? onSubmit : undefined), [autoSave, onSubmit]);
|
|
68
|
+
const { canSubmit, values, errors, handleSubmit, ...inputProps } = useForm<T>({
|
|
69
|
+
schema,
|
|
70
|
+
initialValues,
|
|
71
|
+
onValuesChanged,
|
|
72
|
+
onValidate,
|
|
73
|
+
onValid,
|
|
74
|
+
onSubmit,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Filter and sort props.
|
|
78
|
+
// TODO(burdon): Move into useForm?
|
|
79
|
+
const props = useMemo(() => {
|
|
80
|
+
const props = getSchemaProperties<T>(schema.ast, values);
|
|
81
|
+
const filtered = filter ? filter(props) : props;
|
|
82
|
+
const findIndex = (props: PropertyKey<T>[], prop: PropertyKey<T>) => {
|
|
83
|
+
const idx = props.findIndex((p) => p === prop);
|
|
84
|
+
return idx === -1 ? Infinity : idx;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return sort ? filtered.sort(({ name: a }, { name: b }) => findIndex(sort, a) - findIndex(sort, b)) : filtered;
|
|
88
|
+
}, [schema, filter, values]);
|
|
89
|
+
|
|
90
|
+
// TODO(burdon): Highlight in UX.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (errors && Object.keys(errors).length) {
|
|
93
|
+
log.warn('validation', { errors });
|
|
94
|
+
}
|
|
95
|
+
}, [errors]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div role='form' className={mx('flex flex-col w-full gap-2 py-2', classNames)}>
|
|
99
|
+
{props
|
|
100
|
+
.map(({ prop, name, type, format, title, description, examples }) => {
|
|
101
|
+
// Custom property allows for sub forms.
|
|
102
|
+
// TODO(burdon): Use Select control if options are present in annotation?
|
|
103
|
+
const key = [...path, name];
|
|
104
|
+
const InputComponent = Custom?.[key.join('.')] ?? getInputComponent<T>(type, format);
|
|
105
|
+
if (!InputComponent) {
|
|
106
|
+
// Recursively render form.
|
|
107
|
+
if (type === 'object') {
|
|
108
|
+
const baseNode = findNode(prop.type, isDiscriminatedUnion);
|
|
109
|
+
const typeLiteral = baseNode
|
|
110
|
+
? getDiscriminatedType(baseNode, values[name] as any)
|
|
111
|
+
: findNode(prop.type, AST.isTypeLiteral);
|
|
112
|
+
|
|
113
|
+
if (typeLiteral) {
|
|
114
|
+
const schema = S.make(typeLiteral);
|
|
115
|
+
return (
|
|
116
|
+
<div key={name} role='none'>
|
|
117
|
+
<div className={padding}>{title ?? name}</div>
|
|
118
|
+
<Form<any>
|
|
119
|
+
schema={schema}
|
|
120
|
+
path={key}
|
|
121
|
+
values={values[name] ?? {}}
|
|
122
|
+
onValuesChanged={(childValues) => {
|
|
123
|
+
inputProps.onValueChange(name, 'object', childValues);
|
|
124
|
+
}}
|
|
125
|
+
Custom={Custom as any}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log('no renderer for property', { name, type });
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const placeholder = examples?.length ? `Example: ${examples[0]}` : description;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div key={name} role='none' className={padding}>
|
|
140
|
+
<InputComponent
|
|
141
|
+
type={type}
|
|
142
|
+
format={format}
|
|
143
|
+
property={name}
|
|
144
|
+
disabled={readonly}
|
|
145
|
+
label={title ?? name}
|
|
146
|
+
placeholder={placeholder}
|
|
147
|
+
{...inputProps}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
})
|
|
152
|
+
.filter(isNotFalsy)}
|
|
153
|
+
|
|
154
|
+
{/* {errors && <div className='overflow-hidden text-sm'>{JSON.stringify(errors)}</div>} */}
|
|
155
|
+
|
|
156
|
+
{(onCancel || onSubmit) && !autoSave && (
|
|
157
|
+
<div role='none' className='flex justify-center'>
|
|
158
|
+
<div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
|
|
159
|
+
{onCancel && !readonly && (
|
|
160
|
+
<IconButton icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
|
|
161
|
+
)}
|
|
162
|
+
{onSubmit && (
|
|
163
|
+
<IconButton
|
|
164
|
+
type='submit'
|
|
165
|
+
disabled={!canSubmit}
|
|
166
|
+
icon='ph--check--regular'
|
|
167
|
+
label={t('button save')}
|
|
168
|
+
onClick={handleSubmit}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type FC, type PropsWithChildren } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type FormatEnum } from '@dxos/echo-schema';
|
|
8
|
+
import { type SimpleType } from '@dxos/effect';
|
|
9
|
+
import { Icon, Tooltip } from '@dxos/react-ui';
|
|
10
|
+
import { errorText } from '@dxos/react-ui-theme';
|
|
11
|
+
import { type PropertyKey } from '@dxos/schema';
|
|
12
|
+
|
|
13
|
+
import { type FormHandler } from '../../hooks';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Props passed to input components.
|
|
17
|
+
*/
|
|
18
|
+
export type InputProps<T extends object = {}> = {
|
|
19
|
+
property: PropertyKey<T>;
|
|
20
|
+
type: SimpleType;
|
|
21
|
+
format?: FormatEnum;
|
|
22
|
+
label: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
// TODO(burdon): Provide from annotations? E.g., enum.
|
|
26
|
+
options?: Array<{ value: string | number; label?: string }>;
|
|
27
|
+
} & Pick<FormHandler<T>, 'getStatus' | 'getValue' | 'onValueChange' | 'onBlur'>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Form input component.
|
|
31
|
+
*/
|
|
32
|
+
// TODO(burdon): Remove generic from inputs.
|
|
33
|
+
export type InputComponent<T extends object = {}> = FC<InputProps<T>>;
|
|
34
|
+
|
|
35
|
+
export type InputHeaderProps = PropsWithChildren<{
|
|
36
|
+
error?: string;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
export const InputHeader = ({ children, error }: InputHeaderProps) => {
|
|
40
|
+
return (
|
|
41
|
+
<div role='none' className='flex justify-between items-center mb-1'>
|
|
42
|
+
{children}
|
|
43
|
+
{error && (
|
|
44
|
+
<Tooltip.Root>
|
|
45
|
+
<Tooltip.Trigger asChild>
|
|
46
|
+
<Icon icon='ph--warning--regular' size={4} classNames={errorText} />
|
|
47
|
+
</Tooltip.Trigger>
|
|
48
|
+
<Tooltip.Content side='bottom'>
|
|
49
|
+
<Tooltip.Arrow />
|
|
50
|
+
{error}
|
|
51
|
+
</Tooltip.Content>
|
|
52
|
+
</Tooltip.Root>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { FormatEnum } from '@dxos/echo-schema';
|
|
6
|
+
import { type SimpleType } from '@dxos/effect';
|
|
7
|
+
|
|
8
|
+
import { LatLngInput } from './Custom';
|
|
9
|
+
import { BooleanInput, NumberInput, TextInput } from './Defaults';
|
|
10
|
+
import { type InputComponent } from './Input';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get property input component.
|
|
14
|
+
*/
|
|
15
|
+
export const getInputComponent = <T extends object>(
|
|
16
|
+
type: SimpleType,
|
|
17
|
+
format?: FormatEnum,
|
|
18
|
+
): InputComponent<T> | undefined => {
|
|
19
|
+
switch (format) {
|
|
20
|
+
case FormatEnum.LatLng:
|
|
21
|
+
return LatLngInput;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
switch (type) {
|
|
25
|
+
case 'string':
|
|
26
|
+
return TextInput;
|
|
27
|
+
case 'number':
|
|
28
|
+
return NumberInput;
|
|
29
|
+
case 'boolean':
|
|
30
|
+
return BooleanInput;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react';
|
|
6
|
+
import { within, userEvent, expect, fn } from '@storybook/test';
|
|
7
|
+
import React from 'react';
|
|
8
|
+
|
|
9
|
+
// TODO(burdon): Need node exports.
|
|
10
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
11
|
+
|
|
12
|
+
// https://storybook.js.org/blog/storybook-test
|
|
13
|
+
// https://github.com/storybookjs/storybook/tree/next/code/lib/test
|
|
14
|
+
|
|
15
|
+
// TODO(burdon): Set-up: https://www.chromatic.com/storybook
|
|
16
|
+
|
|
17
|
+
export type TestProps = {
|
|
18
|
+
onClick: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const Test = ({ onClick }: TestProps) => (
|
|
22
|
+
<button className='p-2' onClick={onClick}>
|
|
23
|
+
Test
|
|
24
|
+
</button>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const Primary: StoryObj<TestProps> = {
|
|
28
|
+
// TODO(burdon): Race condition on first load?
|
|
29
|
+
play: async ({ args, canvasElement }: any) => {
|
|
30
|
+
const canvas = within(canvasElement);
|
|
31
|
+
await userEvent.click(canvas.getByRole('button'));
|
|
32
|
+
await expect(args.onClick).toHaveBeenCalled();
|
|
33
|
+
},
|
|
34
|
+
args: {
|
|
35
|
+
onClick: fn(),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const meta: Meta = {
|
|
40
|
+
title: 'ui/react-ui-form/Test',
|
|
41
|
+
component: Test,
|
|
42
|
+
decorators: [withTheme],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default meta;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import { type Meta, type StoryObj } from '@storybook/react';
|
|
8
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { Format, type MutableSchema, S, toJsonSchema, TypedObject } from '@dxos/echo-schema';
|
|
11
|
+
import { useSpace } from '@dxos/react-client/echo';
|
|
12
|
+
import { withClientProvider } from '@dxos/react-client/testing';
|
|
13
|
+
import { ViewProjection, createView } from '@dxos/schema';
|
|
14
|
+
import { type ViewType } from '@dxos/schema';
|
|
15
|
+
import { withTheme, withLayout } from '@dxos/storybook-utils';
|
|
16
|
+
|
|
17
|
+
import { ViewEditor } from './ViewEditor';
|
|
18
|
+
import translations from '../../translations';
|
|
19
|
+
import { TestLayout, TestPanel } from '../testing';
|
|
20
|
+
|
|
21
|
+
const DefaultStory = () => {
|
|
22
|
+
const space = useSpace();
|
|
23
|
+
const [schema, setSchema] = useState<MutableSchema>();
|
|
24
|
+
const [view, setView] = useState<ViewType>();
|
|
25
|
+
const [projection, setProjection] = useState<ViewProjection>();
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (space) {
|
|
28
|
+
class TestSchema extends TypedObject({ typename: 'example.com/type/Test', version: '0.1.0' })({
|
|
29
|
+
name: S.String,
|
|
30
|
+
email: Format.Email,
|
|
31
|
+
salary: Format.Currency(),
|
|
32
|
+
}) {}
|
|
33
|
+
|
|
34
|
+
const schema = space.db.schemaRegistry.addSchema(TestSchema);
|
|
35
|
+
const view = createView({ name: 'Test', typename: schema.typename, jsonSchema: toJsonSchema(TestSchema) });
|
|
36
|
+
const projection = new ViewProjection(schema, view);
|
|
37
|
+
|
|
38
|
+
setSchema(schema);
|
|
39
|
+
setView(view);
|
|
40
|
+
setProjection(projection);
|
|
41
|
+
}
|
|
42
|
+
}, [space]);
|
|
43
|
+
|
|
44
|
+
const handleDelete = useCallback((property: string) => projection?.deleteFieldProjection(property), [projection]);
|
|
45
|
+
|
|
46
|
+
if (!schema || !view || !projection) {
|
|
47
|
+
return <div />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<TestLayout json={{ schema, view, projection }}>
|
|
52
|
+
<TestPanel>
|
|
53
|
+
<ViewEditor schema={schema} view={view} registry={space?.db.schemaRegistry} onDelete={handleDelete} />
|
|
54
|
+
</TestPanel>
|
|
55
|
+
</TestLayout>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const meta: Meta<typeof ViewEditor> = {
|
|
60
|
+
title: 'ui/react-ui-form/ViewEditor',
|
|
61
|
+
component: ViewEditor,
|
|
62
|
+
render: DefaultStory,
|
|
63
|
+
decorators: [withClientProvider({ createSpace: true }), withLayout({ fullscreen: true, tooltips: true }), withTheme],
|
|
64
|
+
parameters: {
|
|
65
|
+
translations,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
|
|
71
|
+
type Story = StoryObj;
|
|
72
|
+
|
|
73
|
+
export const Default: Story = {};
|