@dxos/react-ui-form 0.8.4-main.f9ba587 → 0.8.4-main.fcc0d83b33
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/README.md +0 -1
- package/dist/lib/browser/index.mjs +2787 -2137
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +82 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +2787 -2137
- 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 +84 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +9 -9
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +86 -7
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +109 -25
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +130 -56
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +43 -0
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +79 -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 +15 -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 +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 +83 -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 +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 +84 -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/ObjectForm/ObjectForm.d.ts +9 -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 +68 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +87 -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/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 +127 -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/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 +84 -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 +22 -10
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +87 -9
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +4 -2
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +5 -4
- 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 +65 -54
- 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 +7 -0
- package/dist/types/src/util/properties.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +3 -3
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +57 -46
- package/src/components/FieldEditor/FieldEditor.stories.tsx +52 -31
- package/src/components/FieldEditor/FieldEditor.test.tsx +13 -13
- package/src/components/FieldEditor/FieldEditor.tsx +90 -95
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +147 -223
- package/src/components/Form/Form.tsx +376 -95
- package/src/components/Form/FormField.tsx +306 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +174 -0
- package/src/components/Form/FormFieldSet.tsx +144 -0
- package/src/components/Form/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +159 -0
- package/src/components/Form/fields/BooleanField.tsx +22 -0
- package/src/components/Form/fields/GeoPointField.tsx +121 -0
- package/src/components/Form/fields/MarkdownField.stories.tsx +152 -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 +229 -0
- package/src/components/Form/fields/SelectField.tsx +65 -0
- package/src/components/Form/fields/SelectOptionField.stories.tsx +66 -0
- package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +50 -45
- 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 -10
- package/src/components/Form/fields/index.ts +15 -0
- package/src/components/Form/index.ts +3 -4
- package/src/components/ObjectForm/ObjectForm.tsx +114 -0
- package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +212 -0
- package/src/components/ObjectPicker/ObjectPicker.tsx +194 -0
- package/src/components/ObjectPicker/index.ts +5 -0
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +141 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- package/src/components/Settings/AUDIT.md +52 -0
- package/src/components/Settings/Settings.stories.tsx +104 -0
- package/src/components/Settings/Settings.tsx +82 -0
- package/src/components/Settings/SettingsFieldSet.tsx +257 -0
- package/src/components/Settings/SettingsItem.tsx +62 -0
- package/src/components/Settings/SettingsSelectField.tsx +59 -0
- package/src/components/Settings/index.ts +7 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +145 -61
- package/src/components/ViewEditor/ViewEditor.test.tsx +22 -22
- package/src/components/ViewEditor/ViewEditor.tsx +355 -219
- package/src/components/index.ts +4 -3
- package/src/components/testing.tsx +31 -12
- 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 +70 -58
- package/src/util/field-type.ts +60 -0
- package/src/util/field.ts +33 -43
- package/src/util/formatting.ts +22 -22
- package/src/util/index.ts +2 -1
- package/src/util/properties.ts +18 -0
- package/src/util/refs.ts +17 -19
- 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 -8
- 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 -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/useQueryRefOptions.d.ts +0 -23
- 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 -41
- package/src/components/ControlSection/ControlSection.tsx +0 -139
- 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/SelectOptionsInput.stories.tsx +0 -61
- 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/useQueryRefOptions.ts +0 -53
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -14
|
@@ -5,21 +5,40 @@
|
|
|
5
5
|
import React, { type PropsWithChildren } from 'react';
|
|
6
6
|
|
|
7
7
|
import { type ThemedClassName } from '@dxos/react-ui';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { mx, textBlockWidth } from '@dxos/react-ui-theme';
|
|
8
|
+
import { Syntax } from '@dxos/react-ui-syntax-highlighter';
|
|
9
|
+
import { composableProps, slottable } from '@dxos/ui-theme';
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
<div className='w-full h-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
|
|
14
|
-
<div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
|
|
15
|
-
<JsonFilter data={json} classNames='w-full text-xs' testId='debug' />
|
|
16
|
-
</div>
|
|
17
|
-
);
|
|
11
|
+
type TestLayoutProps = PropsWithChildren<{ json?: any }>;
|
|
18
12
|
|
|
19
|
-
export const
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
export const TestLayout = ({ children, json }: TestLayoutProps) => {
|
|
14
|
+
return (
|
|
15
|
+
<div role='none' className='dx-container grid grid-cols-[1fr_1fr] p-4 gap-4'>
|
|
16
|
+
<TestPanel>{children}</TestPanel>
|
|
17
|
+
<TestPanel>
|
|
18
|
+
<Syntax.Root data={json}>
|
|
19
|
+
<Syntax.Content>
|
|
20
|
+
<Syntax.Filter />
|
|
21
|
+
<Syntax.Viewport>
|
|
22
|
+
<Syntax.Code testId='debug' classNames='text-xs' />
|
|
23
|
+
</Syntax.Viewport>
|
|
24
|
+
</Syntax.Content>
|
|
25
|
+
</Syntax.Root>
|
|
26
|
+
</TestPanel>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type TestPanelProps = ThemedClassName<PropsWithChildren>;
|
|
32
|
+
|
|
33
|
+
const TestPanel = slottable<HTMLDivElement, TestPanelProps>(({ children }, forwardedRef) => {
|
|
34
|
+
return (
|
|
35
|
+
<div {...composableProps({ classNames: 'dx-container bg-modal-surface rounded-md' })} ref={forwardedRef}>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
});
|
|
22
40
|
|
|
23
41
|
// Symbol for accessing debug objects in tests.
|
|
24
42
|
export const VIEW_EDITOR_DEBUG_SYMBOL = Symbol('viewEditorDebug');
|
|
25
43
|
export const FIELD_EDITOR_DEBUG_SYMBOL = Symbol('fieldEditorDebug');
|
|
44
|
+
export const OBJECT_PROPERTIES_DEBUG_SYMBOL = Symbol('objectPropertiesDebug');
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
|
6
|
+
import type * as Schema from 'effect/Schema';
|
|
7
|
+
import type * as SchemaAST from 'effect/SchemaAST';
|
|
8
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { type AnyProperties } from '@dxos/echo/internal';
|
|
11
|
+
import { type JsonPath, createJsonPath, fromEffectValidationPath, getValue as getValue$ } from '@dxos/effect';
|
|
12
|
+
import { log } from '@dxos/log';
|
|
13
|
+
import { useDefaultValue } from '@dxos/react-ui';
|
|
14
|
+
import { type ValidationError, validateSchema } from '@dxos/schema';
|
|
15
|
+
import { type MaybePromise } from '@dxos/util';
|
|
16
|
+
|
|
17
|
+
export type FormFieldStatus = {
|
|
18
|
+
status?: 'error';
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Form properties.
|
|
24
|
+
*/
|
|
25
|
+
export interface FormHandlerProps<T extends AnyProperties> {
|
|
26
|
+
/**
|
|
27
|
+
* Effect schema (Type literal).
|
|
28
|
+
*/
|
|
29
|
+
schema?: Schema.Schema<T, any>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Values for the form if controlled.
|
|
33
|
+
*/
|
|
34
|
+
values?: Partial<T>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default values for the form which will be used if values is not provided.
|
|
38
|
+
*/
|
|
39
|
+
defaultValues?: Partial<T>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Auto-save the form when the values change.
|
|
43
|
+
*/
|
|
44
|
+
autoSave?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Callback for value changes. Note: This is called even when values are invalid.
|
|
48
|
+
* Sometimes the parent component may want to know about changes even if the form is
|
|
49
|
+
* in an invalid state.
|
|
50
|
+
*/
|
|
51
|
+
onValuesChanged?: (values: Partial<T>, meta: FormUpdateMeta<T>) => void;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Custom validation function that runs only after schema validation passes.
|
|
55
|
+
* Use this for complex validation logic that can't be expressed in the schema.
|
|
56
|
+
* @returns Array of validation errors, or undefined if validation passes
|
|
57
|
+
*/
|
|
58
|
+
onValidate?: (values: T) => ValidationError[] | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Called when the form is submitted and passes validation.
|
|
62
|
+
*/
|
|
63
|
+
onSave?: (values: T, meta: FormUpdateMeta<T>) => MaybePromise<void>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Called when the form is canceled.
|
|
67
|
+
*/
|
|
68
|
+
onCancel?: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type FormUpdateMeta<T extends AnyProperties> = {
|
|
72
|
+
changed: FormHandler<T>['changed'];
|
|
73
|
+
isValid: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Form handler properties and methods.
|
|
78
|
+
*/
|
|
79
|
+
export type FormHandler<T extends AnyProperties> = Pick<FormHandlerProps<T>, 'schema' | 'autoSave'> & {
|
|
80
|
+
/** Initial values (which may not pass validation). */
|
|
81
|
+
values: Partial<T>;
|
|
82
|
+
|
|
83
|
+
/** Map of changed fields. */
|
|
84
|
+
changed: Record<JsonPath, boolean>;
|
|
85
|
+
// TODO(burdon): How is this different from above?
|
|
86
|
+
touched: Record<JsonPath, boolean>;
|
|
87
|
+
|
|
88
|
+
/** Map of error strings. */
|
|
89
|
+
errors: Record<JsonPath, string>;
|
|
90
|
+
|
|
91
|
+
/** Whether the form can be saved (i.e., data is valid). */
|
|
92
|
+
isValid: boolean;
|
|
93
|
+
// TODO(burdon): Why is this needed separately from isValid?
|
|
94
|
+
canSave: boolean;
|
|
95
|
+
|
|
96
|
+
onSave: () => void;
|
|
97
|
+
onCancel: () => void;
|
|
98
|
+
|
|
99
|
+
//
|
|
100
|
+
// Form field state management
|
|
101
|
+
//
|
|
102
|
+
|
|
103
|
+
getStatus: (path: string | (string | number)[]) => FormFieldStatus;
|
|
104
|
+
getValue: <V>(path: (string | number)[]) => V | undefined;
|
|
105
|
+
onBlur: (path: (string | number)[]) => void;
|
|
106
|
+
onValueChange: <V>(path: (string | number)[], type: SchemaAST.AST, value: V) => void;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates a hook for managing form state, including values, validation, and submission.
|
|
111
|
+
* Deeply integrated with `@dxos/schema` for schema-based validation.
|
|
112
|
+
*/
|
|
113
|
+
export const useFormHandler = <T extends AnyProperties>({
|
|
114
|
+
schema,
|
|
115
|
+
autoSave,
|
|
116
|
+
values: valuesProp,
|
|
117
|
+
defaultValues: defaultValuesProp,
|
|
118
|
+
onValuesChanged,
|
|
119
|
+
onValidate,
|
|
120
|
+
onSave,
|
|
121
|
+
onCancel,
|
|
122
|
+
...props
|
|
123
|
+
}: FormHandlerProps<T>): FormHandler<T> => {
|
|
124
|
+
const [changed, setChanged] = useState<Record<JsonPath, boolean>>({});
|
|
125
|
+
const [touched, setTouched] = useState<Record<JsonPath, boolean>>({});
|
|
126
|
+
const [errors, setErrors] = useState<Record<JsonPath, string>>({});
|
|
127
|
+
const [saving, setSaving] = useState(false);
|
|
128
|
+
const defaultValues = useDefaultValue<Partial<T>>(defaultValuesProp, () => ({}));
|
|
129
|
+
const [values$, setValues] = useControllableState<Partial<T>>({
|
|
130
|
+
prop: valuesProp,
|
|
131
|
+
defaultProp: defaultValues,
|
|
132
|
+
onChange: () => {
|
|
133
|
+
// Only reset when controlled: parent passed new values.
|
|
134
|
+
// Uncontrolled updates come from setValues in onValueChange.
|
|
135
|
+
if (valuesProp !== undefined) {
|
|
136
|
+
setValues(valuesProp ?? defaultValues);
|
|
137
|
+
setChanged({});
|
|
138
|
+
setTouched({});
|
|
139
|
+
setErrors({});
|
|
140
|
+
setSaving(false);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
// TODO(wittjosiah): Upgrade @radix-ui/react-use-controllable-state.
|
|
145
|
+
const values = values$ as Partial<T>;
|
|
146
|
+
|
|
147
|
+
// Validate.
|
|
148
|
+
const validate = useCallback(
|
|
149
|
+
(values: Partial<T>): values is T => {
|
|
150
|
+
if (!schema) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let errors: ValidationError[] = validateSchema(schema, values) ?? [];
|
|
155
|
+
log('validate', { values, errors });
|
|
156
|
+
if (errors.length === 0 && onValidate) {
|
|
157
|
+
const validatedValues = values as T;
|
|
158
|
+
errors = onValidate(validatedValues) ?? [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setErrors(flatMap(errors));
|
|
162
|
+
const valid = errors.length === 0;
|
|
163
|
+
return valid;
|
|
164
|
+
},
|
|
165
|
+
[schema, onValidate],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Validate on schema change.
|
|
169
|
+
const [schemaChanged, setSchemaChanged] = useState(false);
|
|
170
|
+
useEffect(() => setSchemaChanged(true), [schema]);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (schemaChanged) {
|
|
173
|
+
validate(values);
|
|
174
|
+
setSchemaChanged(false);
|
|
175
|
+
}
|
|
176
|
+
}, [validate, values, schemaChanged]);
|
|
177
|
+
|
|
178
|
+
const isValid = useMemo(() => {
|
|
179
|
+
return Object.keys(errors).length === 0;
|
|
180
|
+
}, [errors]);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* NOTE: We can submit if there is no touched field that has an error.
|
|
184
|
+
* If there's a validation message visible in the form, submit should be disabled.
|
|
185
|
+
*/
|
|
186
|
+
const canSave = useMemo(
|
|
187
|
+
() =>
|
|
188
|
+
!saving &&
|
|
189
|
+
// Check if any error paths that are touched have errors.
|
|
190
|
+
!Object.entries(touched).some(
|
|
191
|
+
([path, isTouched]) =>
|
|
192
|
+
isTouched &&
|
|
193
|
+
Object.keys(errors).some(
|
|
194
|
+
(errorPath) => errorPath === path || errorPath.startsWith(`${path}.`) || errorPath.startsWith(`${path}[`),
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
[touched, errors, saving],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const handleSave = useCallback(async () => {
|
|
201
|
+
if (validate(values)) {
|
|
202
|
+
setSaving(true);
|
|
203
|
+
try {
|
|
204
|
+
await onSave?.(values, { changed, isValid });
|
|
205
|
+
} finally {
|
|
206
|
+
setSaving(false);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}, [values, validate, onSave, changed, isValid]);
|
|
210
|
+
|
|
211
|
+
const handleCancel = useCallback(() => {
|
|
212
|
+
onCancel?.();
|
|
213
|
+
}, [onCancel]);
|
|
214
|
+
|
|
215
|
+
//
|
|
216
|
+
// Fields.
|
|
217
|
+
//
|
|
218
|
+
|
|
219
|
+
const getStatus = useCallback<FormHandler<T>['getStatus']>(
|
|
220
|
+
(path) => {
|
|
221
|
+
const jsonPath = Array.isArray(path) ? createJsonPath(path) : path;
|
|
222
|
+
const [_, error] =
|
|
223
|
+
Object.entries(errors).find(
|
|
224
|
+
([errorPath]) =>
|
|
225
|
+
errorPath === jsonPath || errorPath.startsWith(`${jsonPath}.`) || errorPath.startsWith(`${jsonPath}[`),
|
|
226
|
+
) ?? [];
|
|
227
|
+
|
|
228
|
+
// Only show errors for touched fields.
|
|
229
|
+
const isTouched = touched[jsonPath as JsonPath];
|
|
230
|
+
if (!isTouched) {
|
|
231
|
+
return {
|
|
232
|
+
status: undefined,
|
|
233
|
+
error: undefined,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
status: error ? 'error' : undefined,
|
|
239
|
+
error: error ? (error ?? undefined) : undefined,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
[errors, touched],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const getValue = useCallback<FormHandler<T>['getValue']>(
|
|
246
|
+
(path) => {
|
|
247
|
+
return getValue$(values, createJsonPath(path));
|
|
248
|
+
},
|
|
249
|
+
[values],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const onValueChange = useCallback<FormHandler<T>['onValueChange']>(
|
|
253
|
+
(path, type, value) => {
|
|
254
|
+
log('onValueChange', { path, value });
|
|
255
|
+
|
|
256
|
+
const jsonPath = createJsonPath(path);
|
|
257
|
+
const pathArray = path;
|
|
258
|
+
let parsedValue = value as any;
|
|
259
|
+
try {
|
|
260
|
+
if (type._tag === 'NumberKeyword') {
|
|
261
|
+
parsedValue = parseFloat(value as string) || 0;
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
log.catch(err);
|
|
265
|
+
parsedValue = undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Create merged values for validation and callback. Form never mutates; parent applies via onValuesChanged.
|
|
269
|
+
const newValues = mergeAtPath(values, pathArray as (string | number)[], parsedValue) as Partial<T>;
|
|
270
|
+
|
|
271
|
+
// TODO(burdon): Check value has changed from original.
|
|
272
|
+
const newChanged = { [jsonPath]: true };
|
|
273
|
+
setChanged((prev) => ({ ...prev, ...newChanged }));
|
|
274
|
+
|
|
275
|
+
// Validate.
|
|
276
|
+
const isValid = validate(newValues);
|
|
277
|
+
|
|
278
|
+
// Notify parent; parent is responsible for applying the change.
|
|
279
|
+
onValuesChanged?.(newValues, { isValid, changed: newChanged });
|
|
280
|
+
|
|
281
|
+
// For uncontrolled mode, update internal state. Controlled mode relies on parent to pass new values.
|
|
282
|
+
if (valuesProp === undefined) {
|
|
283
|
+
setValues(newValues);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[values, validate, onValuesChanged, valuesProp],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const onBlur = useCallback(
|
|
290
|
+
async (path: (string | number)[]) => {
|
|
291
|
+
const jsonPath = createJsonPath(path);
|
|
292
|
+
|
|
293
|
+
// TODO(burdon): Check value has changed from original.
|
|
294
|
+
setTouched((touched) => ({ ...touched, [jsonPath]: true }));
|
|
295
|
+
const isValid = validate(values);
|
|
296
|
+
|
|
297
|
+
// Auto-save when a field is blurred and is valid.
|
|
298
|
+
if (Object.keys(changed).length > 0 && isValid && autoSave) {
|
|
299
|
+
await onSave?.(values as T, { changed, isValid });
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
[validate, values, changed, autoSave, onSave],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return useMemo<FormHandler<T>>(
|
|
306
|
+
() => ({
|
|
307
|
+
// State.
|
|
308
|
+
schema,
|
|
309
|
+
values,
|
|
310
|
+
errors,
|
|
311
|
+
touched,
|
|
312
|
+
changed,
|
|
313
|
+
isValid,
|
|
314
|
+
canSave,
|
|
315
|
+
autoSave,
|
|
316
|
+
|
|
317
|
+
// Actions.
|
|
318
|
+
onSave: handleSave,
|
|
319
|
+
onCancel: handleCancel,
|
|
320
|
+
|
|
321
|
+
// Field utils.
|
|
322
|
+
getStatus,
|
|
323
|
+
getValue,
|
|
324
|
+
onBlur,
|
|
325
|
+
onValueChange,
|
|
326
|
+
|
|
327
|
+
...props,
|
|
328
|
+
}),
|
|
329
|
+
[
|
|
330
|
+
schema,
|
|
331
|
+
values,
|
|
332
|
+
errors,
|
|
333
|
+
touched,
|
|
334
|
+
changed,
|
|
335
|
+
canSave,
|
|
336
|
+
isValid,
|
|
337
|
+
getStatus,
|
|
338
|
+
getValue,
|
|
339
|
+
onBlur,
|
|
340
|
+
onValueChange,
|
|
341
|
+
onSave,
|
|
342
|
+
onCancel,
|
|
343
|
+
],
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Creates a new object with value at path. Does not mutate source.
|
|
349
|
+
* Preserves arrays when path segments are numeric indices.
|
|
350
|
+
*/
|
|
351
|
+
const mergeAtPath = (obj: any, path: readonly (string | number)[], value: any): any => {
|
|
352
|
+
if (path.length === 0) {
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const [head, ...rest] = path;
|
|
357
|
+
const child = rest.length === 0 ? value : mergeAtPath(obj?.[head], rest, value);
|
|
358
|
+
|
|
359
|
+
if (Array.isArray(obj)) {
|
|
360
|
+
const copy = [...obj];
|
|
361
|
+
copy[head as number] = child;
|
|
362
|
+
return copy;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { ...obj, [head]: child };
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const flatMap = (errors: ValidationError[]) => {
|
|
369
|
+
return errors.reduce(
|
|
370
|
+
(result, { path, message }) => {
|
|
371
|
+
// Convert the validation error path format to our JsonPath format.
|
|
372
|
+
const jsonPath = fromEffectValidationPath(path);
|
|
373
|
+
if (!(jsonPath in result)) {
|
|
374
|
+
result[jsonPath] = message;
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
},
|
|
378
|
+
{} as Record<JsonPath, string>,
|
|
379
|
+
);
|
|
380
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type RefObject, useCallback, useLayoutEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
import { addEventListener } from '@dxos/async';
|
|
8
|
+
|
|
9
|
+
import { type FormHandler } from './useFormHandler';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Key handler.
|
|
13
|
+
*/
|
|
14
|
+
export const useKeyHandler = (elRef: RefObject<HTMLDivElement | null>, form: FormHandler<any>) => {
|
|
15
|
+
const handleKeyDown = useCallback(
|
|
16
|
+
(event: KeyboardEvent) => {
|
|
17
|
+
switch (event.key) {
|
|
18
|
+
case 'Enter': {
|
|
19
|
+
if (event.metaKey && form.canSave) {
|
|
20
|
+
form.onSave();
|
|
21
|
+
}
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
[form.isValid, form.canSave, form.onSave, form.autoSave],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
const el = elRef.current;
|
|
31
|
+
if (!el) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return addEventListener(el, 'keydown', handleKeyDown);
|
|
36
|
+
}, [elRef, handleKeyDown]);
|
|
37
|
+
};
|
package/src/index.ts
CHANGED
package/src/translations.ts
CHANGED
|
@@ -4,76 +4,88 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Resource } from '@dxos/react-ui';
|
|
6
6
|
|
|
7
|
-
export const translationKey = 'react-ui-form';
|
|
7
|
+
export const translationKey = '@dxos/react-ui-form';
|
|
8
8
|
|
|
9
9
|
export const translations = [
|
|
10
10
|
{
|
|
11
11
|
'en-US': {
|
|
12
12
|
[translationKey]: {
|
|
13
|
-
'fields
|
|
14
|
-
'hidden
|
|
15
|
-
'empty
|
|
13
|
+
'fields.label': 'Fields',
|
|
14
|
+
'hidden-fields.label': 'Hidden Fields',
|
|
15
|
+
'empty-readonly-ref-field.label': '(none)',
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
'field
|
|
20
|
-
'field
|
|
21
|
-
'field format label': 'Type',
|
|
22
|
-
'field path label': 'Field path',
|
|
23
|
-
'add field': 'Add field',
|
|
24
|
-
'delete field': 'Delete field',
|
|
25
|
-
'field limit reached': 'Maximum number of fields reached',
|
|
26
|
-
'boolean input true value': 'Yes',
|
|
27
|
-
'boolean input false value': 'No',
|
|
17
|
+
'field-property.label': 'Property',
|
|
18
|
+
'field-property.placeholder': 'Property name',
|
|
19
|
+
'field-format.label': 'Type',
|
|
20
|
+
'field-path.label': 'Field path',
|
|
28
21
|
|
|
29
|
-
|
|
30
|
-
'button
|
|
31
|
-
'
|
|
22
|
+
'add-item.button': 'Add item',
|
|
23
|
+
'add-property-button.label': 'Add property',
|
|
24
|
+
'boolean-input-true.value': 'Yes',
|
|
25
|
+
'boolean-input-false.value': 'No',
|
|
26
|
+
'show-field.label': 'Show field',
|
|
27
|
+
'hide-field.label': 'Hide field',
|
|
28
|
+
'delete-field.label': 'Delete field',
|
|
29
|
+
'create-option.label': 'Create',
|
|
30
|
+
'add-tag.label': 'Add tag',
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'format date-time': 'DateTime',
|
|
38
|
-
'format did': 'DID',
|
|
39
|
-
'format duration': 'Duration',
|
|
40
|
-
'format dxn': 'DXN',
|
|
41
|
-
'format email': 'Email',
|
|
42
|
-
'format formula': 'Formula',
|
|
43
|
-
'format hostname': 'Hostname',
|
|
44
|
-
'format integer': 'Integer',
|
|
45
|
-
'format json': 'JSON',
|
|
46
|
-
'format latlng': 'Geopoint',
|
|
47
|
-
'format markdown': 'Markdown',
|
|
48
|
-
'format percent': 'Percent',
|
|
49
|
-
'format ref': 'Reference',
|
|
50
|
-
'format regex': 'RegExp',
|
|
51
|
-
'format string': 'String',
|
|
52
|
-
'format single-select': 'Select',
|
|
53
|
-
'format multi-select': 'Multi-select',
|
|
54
|
-
'format text': 'Long text',
|
|
55
|
-
'format time': 'Time',
|
|
56
|
-
'format timestamp': 'Timestamp',
|
|
57
|
-
'format user': 'User',
|
|
58
|
-
'format number': 'Number',
|
|
59
|
-
'format uri': 'URI',
|
|
60
|
-
'format url': 'URL',
|
|
61
|
-
'format uuid': 'UUID',
|
|
32
|
+
'ref-field-combobox-input.placeholder': 'Search…',
|
|
33
|
+
'ref-field.placeholder': 'Select…',
|
|
34
|
+
'ref-field.placeholder_one': 'Select…',
|
|
35
|
+
'ref-field.placeholder_other': 'Select items…',
|
|
62
36
|
|
|
63
|
-
|
|
64
|
-
'placeholder
|
|
65
|
-
'placeholder
|
|
37
|
+
'example.placeholder': 'Example',
|
|
38
|
+
'latitude.placeholder': 'Latitude (e.g., 40.7128)',
|
|
39
|
+
'longitude.placeholder': 'Longitude (e.g., -74.0060)',
|
|
66
40
|
|
|
67
|
-
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
'
|
|
41
|
+
'cancel-button.label': 'Cancel',
|
|
42
|
+
'save-button.label': 'Save',
|
|
43
|
+
'create-text.label': 'Create text',
|
|
44
|
+
|
|
45
|
+
// TypeFormat
|
|
46
|
+
'format.boolean.label': 'Boolean',
|
|
47
|
+
'format.currency.label': 'Currency',
|
|
48
|
+
'format.date.label': 'Date',
|
|
49
|
+
'format.date-time.label': 'DateTime',
|
|
50
|
+
'format.did.label': 'DID',
|
|
51
|
+
'format.duration.label': 'Duration',
|
|
52
|
+
'format.dxn.label': 'DXN',
|
|
53
|
+
'format.email.label': 'Email',
|
|
54
|
+
'format.formula.label': 'Formula',
|
|
55
|
+
'format.hostname.label': 'Hostname',
|
|
56
|
+
'format.integer.label': 'Integer',
|
|
57
|
+
'format.json.label': 'JSON',
|
|
58
|
+
'format.lnglat.label': 'Geopoint',
|
|
59
|
+
'format.markdown.label': 'Markdown',
|
|
60
|
+
'format.percent.label': 'Percent',
|
|
61
|
+
'format.ref.label': 'Reference',
|
|
62
|
+
'format.regex.label': 'RegExp',
|
|
63
|
+
'format.string.label': 'String',
|
|
64
|
+
'format.single-select.label': 'Select',
|
|
65
|
+
'format.multi-select.label': 'Multi-select',
|
|
66
|
+
'format.text.label': 'Long text',
|
|
67
|
+
'format.time.label': 'Time',
|
|
68
|
+
'format.timestamp.label': 'Timestamp',
|
|
69
|
+
'format.user.label': 'User',
|
|
70
|
+
'format.number.label': 'Number',
|
|
71
|
+
'format.uri.label': 'URI',
|
|
72
|
+
'format.url.label': 'URL',
|
|
73
|
+
'format.uuid.label': 'UUID',
|
|
74
|
+
|
|
75
|
+
// GeoPointField.
|
|
76
|
+
'latitude.label': 'Latitude',
|
|
77
|
+
'longitude.label': 'Longitude',
|
|
78
|
+
|
|
79
|
+
// SelectOptionsField.
|
|
80
|
+
'select-option.label': 'Label',
|
|
81
|
+
'select-option-label.placeholder': 'Option label',
|
|
82
|
+
'select-option-delete.button': 'Delete',
|
|
83
|
+
'select-option-add.button': 'Add option',
|
|
73
84
|
|
|
74
85
|
// System schema message.
|
|
75
|
-
'system
|
|
76
|
-
'system
|
|
86
|
+
'system-schema.title': 'System type',
|
|
87
|
+
'system-schema.description': 'System type: cannot add or remove fields.',
|
|
88
|
+
'remove.button': 'Remove',
|
|
77
89
|
},
|
|
78
90
|
},
|
|
79
91
|
},
|