@dxos/react-ui-form 0.8.4-main.2e9d522 → 0.8.4-main.3c1ae3b
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 +2138 -1738
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +2138 -1738
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/ControlSection/ControlSection.d.ts +2 -2
- package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +84 -5
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +5 -5
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +82 -5
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form-old.stories.d.ts +172 -0
- package/dist/types/src/components/Form/Form-old.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.d.ts +78 -25
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +123 -51
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +37 -0
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +78 -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 +19 -0
- package/dist/types/src/components/Form/FormFieldSet.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/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 +4 -0
- package/dist/types/src/components/Form/fields/MarkdownField.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 +18 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts +10 -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 +82 -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 +12 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/index.d.ts +2 -4
- package/dist/types/src/components/Form/index.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +85 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
- package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +21 -9
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +83 -8
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +4 -3
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +3 -2
- 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 +6 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useQueryRefOptions.d.ts +13 -6
- package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
- package/dist/types/src/hooks/useRefQueryOptions.d.ts +8 -0
- package/dist/types/src/hooks/useRefQueryOptions.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +17 -8
- package/dist/types/src/translations.d.ts.map +1 -1
- 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 +1 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +7 -0
- package/dist/types/src/util/properties.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +2 -3
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +43 -36
- package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
- package/src/components/ControlSection/ControlSection.tsx +26 -12
- package/src/components/FieldEditor/FieldEditor.stories.tsx +20 -20
- package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
- package/src/components/FieldEditor/FieldEditor.tsx +75 -77
- package/src/components/Form/Form-old.stories.tsx +314 -0
- package/src/components/Form/Form.stories.tsx +163 -219
- package/src/components/Form/Form.tsx +306 -98
- package/src/components/Form/FormField.tsx +280 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +173 -0
- package/src/components/Form/FormFieldSet.tsx +124 -0
- package/src/components/Form/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +154 -0
- package/src/components/Form/fields/BooleanField.tsx +22 -0
- package/src/components/Form/fields/GeoPointField.tsx +120 -0
- package/src/components/Form/fields/MarkdownField.tsx +78 -0
- package/src/components/Form/fields/NumberField.tsx +39 -0
- package/src/components/Form/fields/RefField.tsx +206 -0
- package/src/components/Form/fields/SelectField.tsx +65 -0
- package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +25 -22
- package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +47 -42
- 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} +15 -8
- package/src/components/Form/fields/index.ts +15 -0
- package/src/components/Form/index.ts +3 -4
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
- package/src/components/ObjectPicker/ObjectPicker.tsx +166 -0
- package/src/components/ObjectPicker/index.ts +5 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +131 -61
- package/src/components/ViewEditor/ViewEditor.test.tsx +19 -17
- package/src/components/ViewEditor/ViewEditor.tsx +277 -197
- package/src/components/index.ts +1 -2
- package/src/components/testing.tsx +20 -10
- package/src/hooks/index.ts +3 -2
- package/src/hooks/useFormHandler.ts +348 -0
- package/src/hooks/useKeyHandler.tsx +37 -0
- package/src/hooks/useQueryRefOptions.ts +49 -32
- package/src/hooks/useRefQueryOptions.ts +37 -0
- package/src/translations.ts +20 -11
- package/src/util/field.ts +33 -43
- package/src/util/formatting.ts +22 -22
- package/src/util/index.ts +1 -1
- package/src/util/properties.ts +18 -0
- package/src/util/refs.ts +12 -18
- 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 -57
- 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 -26
- package/dist/types/src/components/Form/Input.d.ts.map +0 -1
- package/dist/types/src/components/Form/RefField.d.ts +0 -12
- 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 -7
- 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/components/deprecated/Form.d.ts +0 -15
- package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
- package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
- package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
- package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
- package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
- package/dist/types/src/components/deprecated/index.d.ts +0 -2
- package/dist/types/src/components/deprecated/index.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/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/Form/ArrayField.tsx +0 -124
- package/src/components/Form/Defaults.tsx +0 -203
- package/src/components/Form/FormActions.tsx +0 -53
- package/src/components/Form/FormContent.tsx +0 -240
- package/src/components/Form/FormContext.tsx +0 -110
- package/src/components/Form/Input.tsx +0 -51
- package/src/components/Form/RefField.tsx +0 -213
- package/src/components/Form/custom/GeoPointInput.tsx +0 -72
- package/src/components/Form/custom/index.ts +0 -7
- package/src/components/Form/factory.tsx +0 -31
- package/src/components/deprecated/Form.stories.tsx +0 -62
- package/src/components/deprecated/Form.tsx +0 -119
- package/src/components/deprecated/FormInput.tsx +0 -71
- package/src/components/deprecated/index.ts +0 -5
- package/src/hooks/useForm.ts +0 -291
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -14
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { type ChangeEvent, useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Input } from '@dxos/react-ui';
|
|
9
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
10
|
+
|
|
11
|
+
import { type FormFieldComponentProps, FormFieldWrapper } from './FormFieldComponent';
|
|
12
|
+
|
|
13
|
+
const Component = ({
|
|
14
|
+
type,
|
|
15
|
+
placeholder,
|
|
16
|
+
readonly,
|
|
17
|
+
onBlur,
|
|
18
|
+
onValueChange,
|
|
19
|
+
...props
|
|
20
|
+
}: FormFieldComponentProps<string>) => {
|
|
21
|
+
const handleChange = useCallback(
|
|
22
|
+
(event: ChangeEvent<HTMLInputElement>) => onValueChange(type, event.target.value),
|
|
23
|
+
[type, onValueChange],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className='plb-cardSpacingBlock pli-cardSpacingInline'>
|
|
28
|
+
<FormFieldWrapper<string> {...props}>
|
|
29
|
+
{({ value = '' }) => (
|
|
30
|
+
<Input.TextInput
|
|
31
|
+
disabled={!!readonly}
|
|
32
|
+
placeholder={placeholder}
|
|
33
|
+
noAutoFill
|
|
34
|
+
value={value}
|
|
35
|
+
onChange={handleChange}
|
|
36
|
+
onBlur={onBlur}
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
</FormFieldWrapper>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const meta: Meta<typeof Component> = {
|
|
45
|
+
title: 'ui/react-ui-form/FormFieldComponent',
|
|
46
|
+
component: Component,
|
|
47
|
+
decorators: [withTheme, withLayout({ container: 'column' })],
|
|
48
|
+
parameters: {
|
|
49
|
+
layout: 'fullscreen',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
|
|
55
|
+
type Story = StoryObj<typeof meta>;
|
|
56
|
+
|
|
57
|
+
export const Default: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
label: 'Label',
|
|
60
|
+
getStatus: () => ({ status: 'error', error: 'Required field' }),
|
|
61
|
+
getValue: () => 'DXOS',
|
|
62
|
+
onBlur: () => {},
|
|
63
|
+
onValueChange: () => {},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type * as Schema from 'effect/Schema';
|
|
6
|
+
import type * as SchemaAST from 'effect/SchemaAST';
|
|
7
|
+
import React, {
|
|
8
|
+
Component,
|
|
9
|
+
type FC,
|
|
10
|
+
type FocusEvent,
|
|
11
|
+
type PropsWithChildren,
|
|
12
|
+
type ReactElement,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react';
|
|
15
|
+
|
|
16
|
+
import { type Format } from '@dxos/echo/internal';
|
|
17
|
+
import { Icon, Input, Tooltip } from '@dxos/react-ui';
|
|
18
|
+
import { labelSpacing } from '@dxos/react-ui-stack';
|
|
19
|
+
import { errorText, mx } from '@dxos/react-ui-theme';
|
|
20
|
+
|
|
21
|
+
import { type FormFieldStatus } from '../../hooks';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dynamic props passed to input components.
|
|
25
|
+
*/
|
|
26
|
+
export type FormFieldStateProps<T = any> = {
|
|
27
|
+
getStatus: () => FormFieldStatus;
|
|
28
|
+
getValue: () => T | undefined;
|
|
29
|
+
onBlur: (event: FocusEvent<HTMLElement>) => void;
|
|
30
|
+
onValueChange: (type: SchemaAST.AST, value: T) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Presentation mode.
|
|
35
|
+
*
|
|
36
|
+
* - full: Show label, control, and status.
|
|
37
|
+
* - compact: Show label and control.
|
|
38
|
+
* - inline: Control only.
|
|
39
|
+
* - static: Plain DOM; omit all undefined values.
|
|
40
|
+
*/
|
|
41
|
+
// TODO(burdon): 'inline' should be orthogonal to the layout (e.g., array of inline static).
|
|
42
|
+
export type Presentation = 'full' | 'compact' | 'inline' | 'static';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Props passed to input components.
|
|
46
|
+
*/
|
|
47
|
+
export type FormFieldComponentProps<T = any> = {
|
|
48
|
+
type: SchemaAST.AST;
|
|
49
|
+
format?: Format.TypeFormat;
|
|
50
|
+
readonly?: boolean;
|
|
51
|
+
label: string;
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
autoFocus?: boolean;
|
|
54
|
+
layout?: Presentation;
|
|
55
|
+
} & FormFieldStateProps<T>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Form field component.
|
|
59
|
+
*/
|
|
60
|
+
export type FormFieldComponent = FC<FormFieldComponentProps>;
|
|
61
|
+
|
|
62
|
+
export type FormFieldMap = Record<string, FormFieldComponent>;
|
|
63
|
+
|
|
64
|
+
export type FormFieldProvider = (props: {
|
|
65
|
+
prop: string; // TODO(burdon): Path?
|
|
66
|
+
schema: Schema.Schema<any>;
|
|
67
|
+
fieldProps: FormFieldComponentProps;
|
|
68
|
+
}) => ReactElement | undefined;
|
|
69
|
+
|
|
70
|
+
//
|
|
71
|
+
// FormFieldLabel
|
|
72
|
+
//
|
|
73
|
+
|
|
74
|
+
export type FormFieldLabelProps = {
|
|
75
|
+
asChild?: boolean;
|
|
76
|
+
error?: string;
|
|
77
|
+
} & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
|
|
78
|
+
|
|
79
|
+
export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
|
|
80
|
+
const Label = readonly || asChild ? 'span' : Input.Label;
|
|
81
|
+
const labelProps = readonly || asChild ? { className: 'text-description text-sm' } : { classNames: '!mlb-0 text-sm' };
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div role='none' className={mx('flex justify-between items-center', labelSpacing)}>
|
|
85
|
+
<Label {...labelProps}>{label}</Label>
|
|
86
|
+
{error && (
|
|
87
|
+
<Tooltip.Trigger asChild content={error} side='bottom'>
|
|
88
|
+
<Icon icon='ph--warning--regular' size={4} classNames={errorText} />
|
|
89
|
+
</Tooltip.Trigger>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
FormFieldLabel.displayName = 'Form.FieldLabel';
|
|
96
|
+
|
|
97
|
+
//
|
|
98
|
+
// FormFieldWrapper
|
|
99
|
+
//
|
|
100
|
+
|
|
101
|
+
export type FormFieldWrapperProps<T = any> = Pick<
|
|
102
|
+
FormFieldComponentProps,
|
|
103
|
+
'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
|
|
104
|
+
> & {
|
|
105
|
+
children?: (props: { value: T }) => ReactNode;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
|
|
109
|
+
const { children, readonly, layout, label, getStatus, getValue } = props;
|
|
110
|
+
const { status, error } = getStatus();
|
|
111
|
+
|
|
112
|
+
const value = getValue();
|
|
113
|
+
if (layout === 'static' && value == null) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const str = String(value ?? '');
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Input.Root validationValence={status}>
|
|
121
|
+
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
|
|
122
|
+
{layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
|
|
123
|
+
{layout === 'full' && (
|
|
124
|
+
<Input.DescriptionAndValidation>
|
|
125
|
+
<Input.Validation>{error}</Input.Validation>
|
|
126
|
+
</Input.DescriptionAndValidation>
|
|
127
|
+
)}
|
|
128
|
+
</Input.Root>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
//
|
|
133
|
+
// FormFieldErrorBoundary
|
|
134
|
+
//
|
|
135
|
+
|
|
136
|
+
type FormFieldErrorState = {
|
|
137
|
+
error: Error | undefined;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type FormFieldErrorBoundaryProps = PropsWithChildren<{
|
|
141
|
+
path?: (string | number)[];
|
|
142
|
+
}>;
|
|
143
|
+
|
|
144
|
+
export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProps, FormFieldErrorState> {
|
|
145
|
+
static getDerivedStateFromError(error: Error): { error: Error } {
|
|
146
|
+
return { error };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override state = { error: undefined };
|
|
150
|
+
|
|
151
|
+
override componentDidUpdate(prevProps: FormFieldErrorBoundaryProps) {
|
|
152
|
+
if (prevProps.path !== this.props.path) {
|
|
153
|
+
this.resetError();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
override render() {
|
|
158
|
+
if (this.state.error) {
|
|
159
|
+
return (
|
|
160
|
+
<div className='flex gap-2 border border-roseFill font-mono text-sm'>
|
|
161
|
+
<span className='bg-roseFill text-surfaceText pli-1 font-thin'>ERROR</span>
|
|
162
|
+
{String(this.props.path?.join('.'))}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return this.props.children;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private resetError() {
|
|
171
|
+
this.setState({ error: undefined });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { forwardRef, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type AnyProperties } from '@dxos/echo/internal';
|
|
8
|
+
import { type SchemaProperty } from '@dxos/effect';
|
|
9
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
10
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
11
|
+
import { isTruthy } from '@dxos/util';
|
|
12
|
+
|
|
13
|
+
import { type FormHandlerProps } from '../../hooks';
|
|
14
|
+
import { getFormProperties } from '../../util';
|
|
15
|
+
|
|
16
|
+
import { useFormValues } from './Form';
|
|
17
|
+
import { FormField, type FormFieldProps } from './FormField';
|
|
18
|
+
import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
|
|
19
|
+
|
|
20
|
+
export type FormFieldSetProps<T extends AnyProperties> = ThemedClassName<
|
|
21
|
+
{
|
|
22
|
+
label?: string;
|
|
23
|
+
exclude?: (props: SchemaProperty[]) => SchemaProperty[];
|
|
24
|
+
sort?: string[];
|
|
25
|
+
} & Pick<FormHandlerProps<T>, 'schema'> &
|
|
26
|
+
Pick<
|
|
27
|
+
FormFieldProps,
|
|
28
|
+
| 'path'
|
|
29
|
+
| 'autoFocus'
|
|
30
|
+
| 'readonly'
|
|
31
|
+
| 'layout'
|
|
32
|
+
| 'projection'
|
|
33
|
+
| 'fieldMap'
|
|
34
|
+
| 'fieldProvider'
|
|
35
|
+
| 'createSchema'
|
|
36
|
+
| 'createOptionLabel'
|
|
37
|
+
| 'createOptionIcon'
|
|
38
|
+
| 'createInitialValuePath'
|
|
39
|
+
| 'onCreate'
|
|
40
|
+
| 'onQueryRefOptions'
|
|
41
|
+
>
|
|
42
|
+
>;
|
|
43
|
+
|
|
44
|
+
export const FormFieldSet = forwardRef<HTMLDivElement, FormFieldSetProps<any>>(
|
|
45
|
+
({ classNames, label, schema, readonly, path, exclude, sort, projection, layout, ...props }, forwardRef) => {
|
|
46
|
+
const values = useFormValues(FormFieldSet.displayName!, path);
|
|
47
|
+
|
|
48
|
+
// TODO(burdon): Updates on every value change.
|
|
49
|
+
// Remove values dep if can remove from getSchemaProperties.
|
|
50
|
+
const properties = useMemo(() => {
|
|
51
|
+
if (!schema) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
|
|
56
|
+
const props = getFormProperties(schema.ast);
|
|
57
|
+
|
|
58
|
+
// Use projection-based field management when view and projection are available.
|
|
59
|
+
if (projection) {
|
|
60
|
+
const fieldProjections = projection.getFieldProjections();
|
|
61
|
+
const hiddenProperties = new Set(projection.getHiddenProperties());
|
|
62
|
+
|
|
63
|
+
// Filter properties to only include visible ones and order by projection.
|
|
64
|
+
const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name.toString()));
|
|
65
|
+
const orderedProps: SchemaProperty[] = [];
|
|
66
|
+
|
|
67
|
+
// Add properties in projection field order.
|
|
68
|
+
for (const fieldProjection of fieldProjections) {
|
|
69
|
+
const fieldPath = String(fieldProjection.field.path);
|
|
70
|
+
const prop = visibleProps.find((p) => p.name === fieldPath);
|
|
71
|
+
if (prop) {
|
|
72
|
+
orderedProps.push(prop);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Add any remaining properties not in projection.
|
|
77
|
+
const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
|
|
78
|
+
const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
|
|
79
|
+
orderedProps.push(...remainingProps);
|
|
80
|
+
return orderedProps;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback to legacy filter/sort behavior.
|
|
84
|
+
const filtered = exclude ? exclude(props) : props;
|
|
85
|
+
return sort
|
|
86
|
+
? filtered.sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
|
|
87
|
+
: filtered;
|
|
88
|
+
}, [schema, values, exclude, sort, projection?.fields]);
|
|
89
|
+
|
|
90
|
+
if ((readonly || layout === 'static') && values == null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
role='form'
|
|
97
|
+
className={mx('is-full', layout === 'inline' && 'flex flex-col gap-2', classNames)}
|
|
98
|
+
ref={forwardRef}
|
|
99
|
+
>
|
|
100
|
+
{layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
|
|
101
|
+
{properties
|
|
102
|
+
.map((property) => {
|
|
103
|
+
const name = property.name.toString();
|
|
104
|
+
return (
|
|
105
|
+
<FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
|
|
106
|
+
<FormField
|
|
107
|
+
type={property.type}
|
|
108
|
+
name={name}
|
|
109
|
+
path={[...(path ?? []), name]}
|
|
110
|
+
readonly={readonly}
|
|
111
|
+
layout={layout}
|
|
112
|
+
projection={projection}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
</FormFieldErrorBoundary>
|
|
116
|
+
);
|
|
117
|
+
})
|
|
118
|
+
.filter(isTruthy)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
FormFieldSet.displayName = 'Form.Fields';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
6
|
+
import * as SchemaAST from 'effect/SchemaAST';
|
|
7
|
+
import React, { Fragment, useCallback } from 'react';
|
|
8
|
+
|
|
9
|
+
import { findNode, getArrayElementType, getDiscriminatedType, isDiscriminatedUnion, isNestedType } from '@dxos/effect';
|
|
10
|
+
import { invariant } from '@dxos/invariant';
|
|
11
|
+
import { IconButton, useTranslation } from '@dxos/react-ui';
|
|
12
|
+
|
|
13
|
+
import { translationKey } from '../../../translations';
|
|
14
|
+
import { getFormProperties } from '../../../util';
|
|
15
|
+
import { useFormValues } from '../Form';
|
|
16
|
+
import { FormField, type FormFieldProps } from '../FormField';
|
|
17
|
+
import { FormFieldLabel, type FormFieldStateProps } from '../FormFieldComponent';
|
|
18
|
+
|
|
19
|
+
export type ArrayFieldProps = {
|
|
20
|
+
label: string;
|
|
21
|
+
fieldProps: FormFieldStateProps;
|
|
22
|
+
} & FormFieldProps;
|
|
23
|
+
|
|
24
|
+
export const ArrayField = ({
|
|
25
|
+
type,
|
|
26
|
+
path,
|
|
27
|
+
label,
|
|
28
|
+
readonly,
|
|
29
|
+
layout,
|
|
30
|
+
fieldProps: inputProps,
|
|
31
|
+
...props
|
|
32
|
+
}: ArrayFieldProps) => {
|
|
33
|
+
const { t } = useTranslation(translationKey);
|
|
34
|
+
const elementType = getArrayElementType(type);
|
|
35
|
+
const { onValueChange } = inputProps;
|
|
36
|
+
|
|
37
|
+
// TODO(wittjosiah): The fallback to an empty array stops the form from crashing but isn't immediately live.
|
|
38
|
+
// It doesn't become live until another field is touched, but that's better than the whole form crashing.
|
|
39
|
+
const values = useFormValues(ArrayField.displayName, path) ?? [];
|
|
40
|
+
invariant(Array.isArray(values), `Expected array at: ${path?.join('.')}`);
|
|
41
|
+
|
|
42
|
+
const getDefaultObjectValue = (typeNode: SchemaAST.AST): any => {
|
|
43
|
+
const baseNode = findNode(typeNode, isDiscriminatedUnion);
|
|
44
|
+
const typeLiteral = baseNode ? getDiscriminatedType(baseNode, {}) : findNode(typeNode, SchemaAST.isTypeLiteral);
|
|
45
|
+
if (!typeLiteral) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
getFormProperties(typeLiteral).map((prop) => {
|
|
51
|
+
const defaultValue = SchemaAST.getDefaultAnnotation(prop.type).pipe((annotation) =>
|
|
52
|
+
Option.getOrUndefined(annotation),
|
|
53
|
+
);
|
|
54
|
+
return [prop.name, defaultValue];
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleAdd = useCallback(() => {
|
|
60
|
+
const defaultValue =
|
|
61
|
+
isNestedType(type) && elementType ? getDefaultObjectValue(elementType) : getDefaultValue(elementType);
|
|
62
|
+
onValueChange(type, [...values, defaultValue]);
|
|
63
|
+
}, [onValueChange, type, elementType, values]);
|
|
64
|
+
|
|
65
|
+
const handleDelete = useCallback(
|
|
66
|
+
(idx: number) => {
|
|
67
|
+
onValueChange(
|
|
68
|
+
type,
|
|
69
|
+
values.filter((_, i) => i !== idx),
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
[onValueChange, type, values],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!elementType || ((readonly || layout === 'static') && values.length < 1)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{(layout !== 'static' || values.length > 0) && <FormFieldLabel readonly={readonly} label={label} asChild />}
|
|
82
|
+
|
|
83
|
+
<div role='none' className='flex flex-col gap-2'>
|
|
84
|
+
{values.map((_, index) => {
|
|
85
|
+
return (
|
|
86
|
+
<div role='none' key={index} className='grid grid-cols-[1fr_min-content] gap-2 last:mb-3'>
|
|
87
|
+
<FormField
|
|
88
|
+
autoFocus={index === values.length - 1}
|
|
89
|
+
type={elementType}
|
|
90
|
+
path={[...(path ?? []), index]}
|
|
91
|
+
readonly={readonly || layout === 'static'}
|
|
92
|
+
layout='inline'
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
|
|
96
|
+
{!readonly && layout !== 'static' && (
|
|
97
|
+
<div role='none' className='flex flex-col bs-full justify-end'>
|
|
98
|
+
{/* NOTE: Aligns with center of last field if multi-field object. */}
|
|
99
|
+
<div role='none' className='flex items-center bs-[var(--line-height)]'>
|
|
100
|
+
<IconButton
|
|
101
|
+
icon='ph--x--regular'
|
|
102
|
+
iconOnly
|
|
103
|
+
label={t('button remove')}
|
|
104
|
+
onClick={() => handleDelete(index)}
|
|
105
|
+
classNames='self-center'
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* TODO(burdon): Get label from schema. */}
|
|
116
|
+
{!readonly && layout !== 'static' && (
|
|
117
|
+
<IconButton
|
|
118
|
+
classNames='flex is-full _mlb-cardSpacingBlock'
|
|
119
|
+
icon='ph--plus--regular'
|
|
120
|
+
label={t('add field')}
|
|
121
|
+
onClick={handleAdd}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
ArrayField.displayName = 'Form.ArrayField';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Returns the default empty value for a given AST.
|
|
132
|
+
* Used for initializing new array values etc.
|
|
133
|
+
*/
|
|
134
|
+
// TODO(wittjosiah): Factor out?
|
|
135
|
+
export const getDefaultValue = (ast?: SchemaAST.AST): any => {
|
|
136
|
+
switch (ast?._tag) {
|
|
137
|
+
case 'StringKeyword': {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
case 'NumberKeyword': {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
case 'BooleanKeyword': {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
default: {
|
|
147
|
+
if (ast && isNestedType(ast)) {
|
|
148
|
+
return {};
|
|
149
|
+
} else {
|
|
150
|
+
throw new Error(`Unsupported type: ${ast?._tag}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useCallback } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Input, type SwitchProps } from '@dxos/react-ui';
|
|
8
|
+
|
|
9
|
+
import { type FormFieldComponentProps, FormFieldWrapper } from '../FormFieldComponent';
|
|
10
|
+
|
|
11
|
+
export const BooleanField = ({ type, readonly, onValueChange, ...props }: FormFieldComponentProps<boolean>) => {
|
|
12
|
+
const handleChange = useCallback<NonNullable<SwitchProps['onCheckedChange']>>(
|
|
13
|
+
(value) => onValueChange?.(type, value),
|
|
14
|
+
[type, onValueChange],
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<FormFieldWrapper<boolean> readonly={readonly} {...props}>
|
|
19
|
+
{({ value }) => <Input.Switch disabled={!!readonly} checked={value} onCheckedChange={handleChange} />}
|
|
20
|
+
</FormFieldWrapper>
|
|
21
|
+
);
|
|
22
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { GeoLocation, type GeoPoint } from '@dxos/echo/internal';
|
|
8
|
+
import { Input, useTranslation } from '@dxos/react-ui';
|
|
9
|
+
import { safeParseFloat } from '@dxos/util';
|
|
10
|
+
|
|
11
|
+
import { translationKey } from '../../../translations';
|
|
12
|
+
import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
|
|
13
|
+
|
|
14
|
+
export const GeoPointField = ({
|
|
15
|
+
type,
|
|
16
|
+
label,
|
|
17
|
+
readonly,
|
|
18
|
+
layout,
|
|
19
|
+
getStatus,
|
|
20
|
+
getValue,
|
|
21
|
+
onValueChange,
|
|
22
|
+
onBlur,
|
|
23
|
+
}: FormFieldComponentProps<GeoPoint>) => {
|
|
24
|
+
const { t } = useTranslation(translationKey);
|
|
25
|
+
const { status, error } = getStatus();
|
|
26
|
+
const geoPoint = useMemo<GeoPoint>(() => getValue() ?? [0, 0], [getValue]);
|
|
27
|
+
const value = useMemo(() => GeoLocation.fromGeoPoint(geoPoint), [geoPoint]);
|
|
28
|
+
|
|
29
|
+
const [longitudeText, setLongitudeText] = useState(value.longitude?.toString());
|
|
30
|
+
const [latitudeText, setLatitudeText] = useState(value.latitude?.toString());
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const location = GeoLocation.fromGeoPoint(geoPoint);
|
|
33
|
+
setLongitudeText(location.longitude?.toString());
|
|
34
|
+
setLatitudeText(location.latitude?.toString());
|
|
35
|
+
}, [geoPoint]);
|
|
36
|
+
|
|
37
|
+
const handleChange = useCallback(
|
|
38
|
+
(coordinateType: keyof Pick<GeoLocation, 'longitude' | 'latitude'>, setText: (text: string) => void) =>
|
|
39
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const inputText = event.target.value;
|
|
41
|
+
setText(inputText);
|
|
42
|
+
if (inputText !== '' && inputText !== '-') {
|
|
43
|
+
const coord = safeParseFloat(inputText);
|
|
44
|
+
if (coord !== undefined && !isNaN(coord)) {
|
|
45
|
+
const currentLocation = GeoLocation.fromGeoPoint(getValue() ?? [0, 0]);
|
|
46
|
+
const newLocation = { ...currentLocation, [coordinateType]: coord };
|
|
47
|
+
const newValue = GeoLocation.toGeoPoint(newLocation);
|
|
48
|
+
onValueChange(type, newValue);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[type, getValue, onValueChange],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if ((readonly || layout === 'static') && !value?.latitude && !value?.longitude) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Input.Root validationValence={status}>
|
|
61
|
+
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} asChild />}
|
|
62
|
+
{layout === 'static' ? (
|
|
63
|
+
<LatLng {...value} />
|
|
64
|
+
) : (
|
|
65
|
+
<div role='none' className='grid grid-cols-2 gap-2'>
|
|
66
|
+
<div>
|
|
67
|
+
<Input.Root>
|
|
68
|
+
{layout !== 'inline' && <Input.Label>{t('latitude label')}</Input.Label>}
|
|
69
|
+
<Input.TextInput
|
|
70
|
+
type='number'
|
|
71
|
+
step='0.00001'
|
|
72
|
+
min='-90'
|
|
73
|
+
max='90'
|
|
74
|
+
disabled={!!readonly}
|
|
75
|
+
placeholder={t('latitude placeholder')}
|
|
76
|
+
value={latitudeText ?? ''}
|
|
77
|
+
onChange={handleChange('latitude', setLatitudeText)}
|
|
78
|
+
onBlur={onBlur}
|
|
79
|
+
/>
|
|
80
|
+
</Input.Root>
|
|
81
|
+
</div>
|
|
82
|
+
<div>
|
|
83
|
+
<Input.Root>
|
|
84
|
+
{layout !== 'inline' && <Input.Label>{t('longitude label')}</Input.Label>}
|
|
85
|
+
<Input.TextInput
|
|
86
|
+
type='number'
|
|
87
|
+
step='0.00001'
|
|
88
|
+
min='-180'
|
|
89
|
+
max='180'
|
|
90
|
+
disabled={!!readonly}
|
|
91
|
+
placeholder={t('longitude placeholder')}
|
|
92
|
+
value={longitudeText ?? ''}
|
|
93
|
+
onChange={handleChange('longitude', setLongitudeText)}
|
|
94
|
+
onBlur={onBlur}
|
|
95
|
+
/>
|
|
96
|
+
</Input.Root>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</Input.Root>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const LatLng = ({ latitude = 0, longitude = 0 }: GeoLocation) => {
|
|
105
|
+
const latHem = latitude >= 0 ? 'N' : 'S';
|
|
106
|
+
const lngHem = longitude >= 0 ? 'E' : 'W';
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<span className='inline-flex items-center gap-1'>
|
|
110
|
+
<span>
|
|
111
|
+
<span>{Math.abs(latitude).toFixed(5)}</span>
|
|
112
|
+
<span className='text-subdued'>°{latHem}</span>
|
|
113
|
+
</span>
|
|
114
|
+
<span>
|
|
115
|
+
<span>{Math.abs(longitude).toFixed(5)}</span>
|
|
116
|
+
<span className='text-subdued'>°{lngHem}</span>
|
|
117
|
+
</span>
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
};
|