@dxos/react-ui-form 0.8.4-main.b97322e → 0.8.4-main.bc674ce
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 +2220 -2058
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +2220 -2058
- 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 +4 -4
- 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 +6 -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 +131 -0
- package/dist/types/src/components/Form/Form-old.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.d.ts +81 -25
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +127 -55
- 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 +14 -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 +46 -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 +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 +6 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +19 -10
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/echo.d.ts +6 -0
- package/dist/types/src/util/echo.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 +2 -3
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +51 -40
- package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
- package/src/components/ControlSection/ControlSection.tsx +30 -16
- package/src/components/FieldEditor/FieldEditor.stories.tsx +52 -31
- package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
- package/src/components/FieldEditor/FieldEditor.tsx +86 -81
- package/src/components/Form/Form-old.stories.tsx +314 -0
- package/src/components/Form/Form.stories.tsx +155 -219
- package/src/components/Form/Form.tsx +320 -96
- package/src/components/Form/FormField.tsx +286 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +174 -0
- package/src/components/Form/FormFieldSet.tsx +125 -0
- package/src/components/Form/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +151 -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 +181 -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} +16 -9
- package/src/components/Form/fields/index.ts +15 -0
- package/src/components/Form/index.ts +3 -4
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +214 -0
- package/src/components/ObjectPicker/ObjectPicker.tsx +175 -0
- package/src/components/ObjectPicker/index.ts +5 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +137 -61
- package/src/components/ViewEditor/ViewEditor.test.tsx +19 -17
- package/src/components/ViewEditor/ViewEditor.tsx +324 -203
- package/src/components/index.ts +1 -2
- package/src/components/testing.tsx +19 -11
- package/src/hooks/index.ts +2 -3
- package/src/hooks/useFormHandler.ts +362 -0
- package/src/hooks/useKeyHandler.tsx +37 -0
- package/src/translations.ts +21 -12
- package/src/util/echo.ts +25 -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 +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/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/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/useQueryRefOptions.ts +0 -53
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -14
|
@@ -0,0 +1,362 @@
|
|
|
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 { Obj } from '@dxos/echo';
|
|
11
|
+
import { type AnyProperties } from '@dxos/echo/internal';
|
|
12
|
+
import {
|
|
13
|
+
type JsonPath,
|
|
14
|
+
createJsonPath,
|
|
15
|
+
fromEffectValidationPath,
|
|
16
|
+
getValue as getValue$,
|
|
17
|
+
setValue as setValue$,
|
|
18
|
+
} from '@dxos/effect';
|
|
19
|
+
import { log } from '@dxos/log';
|
|
20
|
+
import { useDefaultValue } from '@dxos/react-ui';
|
|
21
|
+
import { type ValidationError, validateSchema } from '@dxos/schema';
|
|
22
|
+
import { type MaybePromise } from '@dxos/util';
|
|
23
|
+
|
|
24
|
+
import { setValueEchoAware } from '../util';
|
|
25
|
+
|
|
26
|
+
export type FormFieldStatus = {
|
|
27
|
+
status?: 'error';
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Form properties.
|
|
33
|
+
*/
|
|
34
|
+
export interface FormHandlerProps<T extends AnyProperties> {
|
|
35
|
+
/**
|
|
36
|
+
* Effect schema (Type literal).
|
|
37
|
+
*/
|
|
38
|
+
schema?: Schema.Schema<T, any>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Values for the form if controlled.
|
|
42
|
+
*/
|
|
43
|
+
values?: Partial<T>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default values for the form which will be used if values is not provided.
|
|
47
|
+
*/
|
|
48
|
+
defaultValues?: Partial<T>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Auto-save the form when the values change.
|
|
52
|
+
*/
|
|
53
|
+
autoSave?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Callback for value changes. Note: This is called even when values are invalid.
|
|
57
|
+
* Sometimes the parent component may want to know about changes even if the form is
|
|
58
|
+
* in an invalid state.
|
|
59
|
+
*/
|
|
60
|
+
onValuesChanged?: (values: Partial<T>, meta: FormUpdateMeta<T>) => void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Custom validation function that runs only after schema validation passes.
|
|
64
|
+
* Use this for complex validation logic that can't be expressed in the schema.
|
|
65
|
+
* @returns Array of validation errors, or undefined if validation passes
|
|
66
|
+
*/
|
|
67
|
+
onValidate?: (values: T) => ValidationError[] | undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Called when the form is submitted and passes validation.
|
|
71
|
+
*/
|
|
72
|
+
onSave?: (values: T, meta: FormUpdateMeta<T>) => MaybePromise<void>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called when the form is canceled.
|
|
76
|
+
*/
|
|
77
|
+
onCancel?: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FormUpdateMeta<T extends AnyProperties> = {
|
|
81
|
+
changed: FormHandler<T>['changed'];
|
|
82
|
+
isValid: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Form handler properties and methods.
|
|
87
|
+
*/
|
|
88
|
+
export type FormHandler<T extends AnyProperties> = Pick<FormHandlerProps<T>, 'schema' | 'autoSave'> & {
|
|
89
|
+
/** Initial values (which may not pass validation). */
|
|
90
|
+
values: Partial<T>;
|
|
91
|
+
|
|
92
|
+
/** Map of changed fields. */
|
|
93
|
+
changed: Record<JsonPath, boolean>;
|
|
94
|
+
// TODO(burdon): How is this different from above?
|
|
95
|
+
touched: Record<JsonPath, boolean>;
|
|
96
|
+
|
|
97
|
+
/** Map of error strings. */
|
|
98
|
+
errors: Record<JsonPath, string>;
|
|
99
|
+
|
|
100
|
+
/** Whether the form can be saved (i.e., data is valid). */
|
|
101
|
+
isValid: boolean;
|
|
102
|
+
// TODO(burdon): Why is this needed separately from isValid?
|
|
103
|
+
canSave: boolean;
|
|
104
|
+
|
|
105
|
+
onSave: () => void;
|
|
106
|
+
onCancel: () => void;
|
|
107
|
+
|
|
108
|
+
//
|
|
109
|
+
// Form field state management
|
|
110
|
+
//
|
|
111
|
+
|
|
112
|
+
getStatus: (path: string | (string | number)[]) => FormFieldStatus;
|
|
113
|
+
getValue: <V>(path: (string | number)[]) => V | undefined;
|
|
114
|
+
onBlur: (path: (string | number)[]) => void;
|
|
115
|
+
onValueChange: <V>(path: (string | number)[], type: SchemaAST.AST, value: V) => void;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Creates a hook for managing form state, including values, validation, and submission.
|
|
120
|
+
* Deeply integrated with `@dxos/schema` for schema-based validation.
|
|
121
|
+
*/
|
|
122
|
+
export const useFormHandler = <T extends AnyProperties>({
|
|
123
|
+
schema,
|
|
124
|
+
autoSave,
|
|
125
|
+
values: valuesProp,
|
|
126
|
+
defaultValues: defaultValuesProp,
|
|
127
|
+
onValuesChanged,
|
|
128
|
+
onValidate,
|
|
129
|
+
onSave,
|
|
130
|
+
onCancel,
|
|
131
|
+
...props
|
|
132
|
+
}: FormHandlerProps<T>): FormHandler<T> => {
|
|
133
|
+
const [changed, setChanged] = useState<Record<JsonPath, boolean>>({});
|
|
134
|
+
const [touched, setTouched] = useState<Record<JsonPath, boolean>>({});
|
|
135
|
+
const [errors, setErrors] = useState<Record<JsonPath, string>>({});
|
|
136
|
+
const [saving, setSaving] = useState(false);
|
|
137
|
+
const defaultValues = useDefaultValue<Partial<T>>(defaultValuesProp, () => ({}));
|
|
138
|
+
const [values$, setValues] = useControllableState<Partial<T>>({
|
|
139
|
+
prop: valuesProp,
|
|
140
|
+
defaultProp: defaultValues,
|
|
141
|
+
onChange: () => {
|
|
142
|
+
setValues(valuesProp ?? defaultValues);
|
|
143
|
+
setChanged({});
|
|
144
|
+
setTouched({});
|
|
145
|
+
setErrors({});
|
|
146
|
+
setSaving(false);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
// TODO(wittjosiah): Upgrade @radix-ui/react-use-controllable-state.
|
|
150
|
+
const values = values$ as Partial<T>;
|
|
151
|
+
|
|
152
|
+
// Validate.
|
|
153
|
+
const validate = useCallback(
|
|
154
|
+
(values: Partial<T>): values is T => {
|
|
155
|
+
if (!schema) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let errors: ValidationError[] = validateSchema(schema, values) ?? [];
|
|
160
|
+
log('validate', { values, errors });
|
|
161
|
+
if (errors.length === 0 && onValidate) {
|
|
162
|
+
const validatedValues = values as T;
|
|
163
|
+
errors = onValidate(validatedValues) ?? [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setErrors(flatMap(errors));
|
|
167
|
+
const valid = errors.length === 0;
|
|
168
|
+
return valid;
|
|
169
|
+
},
|
|
170
|
+
[schema, onValidate],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Validate on schema change.
|
|
174
|
+
const [schemaChanged, setSchemaChanged] = useState(false);
|
|
175
|
+
useEffect(() => setSchemaChanged(true), [schema]);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (schemaChanged) {
|
|
178
|
+
validate(values);
|
|
179
|
+
setSchemaChanged(false);
|
|
180
|
+
}
|
|
181
|
+
}, [validate, values, schemaChanged]);
|
|
182
|
+
|
|
183
|
+
const isValid = useMemo(() => {
|
|
184
|
+
return Object.keys(errors).length === 0;
|
|
185
|
+
}, [errors]);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* NOTE: We can submit if there is no touched field that has an error.
|
|
189
|
+
* If there's a validation message visible in the form, submit should be disabled.
|
|
190
|
+
*/
|
|
191
|
+
const canSave = useMemo(
|
|
192
|
+
() =>
|
|
193
|
+
!saving &&
|
|
194
|
+
// Check if any error paths that are touched have errors.
|
|
195
|
+
!Object.entries(touched).some(
|
|
196
|
+
([path, isTouched]) =>
|
|
197
|
+
isTouched &&
|
|
198
|
+
Object.keys(errors).some(
|
|
199
|
+
(errorPath) => errorPath === path || errorPath.startsWith(`${path}.`) || errorPath.startsWith(`${path}[`),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
[touched, errors, saving],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const handleSave = useCallback(async () => {
|
|
206
|
+
if (validate(values)) {
|
|
207
|
+
setSaving(true);
|
|
208
|
+
try {
|
|
209
|
+
await onSave?.(values, { changed, isValid });
|
|
210
|
+
} finally {
|
|
211
|
+
setSaving(false);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}, [values, validate, onSave, changed, isValid]);
|
|
215
|
+
|
|
216
|
+
const handleCancel = useCallback(() => {
|
|
217
|
+
onCancel?.();
|
|
218
|
+
}, [onCancel]);
|
|
219
|
+
|
|
220
|
+
//
|
|
221
|
+
// Fields.
|
|
222
|
+
//
|
|
223
|
+
|
|
224
|
+
const getStatus = useCallback<FormHandler<T>['getStatus']>(
|
|
225
|
+
(path) => {
|
|
226
|
+
const jsonPath = Array.isArray(path) ? createJsonPath(path) : path;
|
|
227
|
+
const [_, error] =
|
|
228
|
+
Object.entries(errors).find(
|
|
229
|
+
([errorPath]) =>
|
|
230
|
+
errorPath === jsonPath || errorPath.startsWith(`${jsonPath}.`) || errorPath.startsWith(`${jsonPath}[`),
|
|
231
|
+
) ?? [];
|
|
232
|
+
|
|
233
|
+
// Only show errors for touched fields.
|
|
234
|
+
const isTouched = touched[jsonPath as JsonPath];
|
|
235
|
+
if (!isTouched) {
|
|
236
|
+
return {
|
|
237
|
+
status: undefined,
|
|
238
|
+
error: undefined,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
status: error ? 'error' : undefined,
|
|
244
|
+
error: error ? (error ?? undefined) : undefined,
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
[errors, touched],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const getValue = useCallback<FormHandler<T>['getValue']>(
|
|
251
|
+
(path) => {
|
|
252
|
+
return getValue$(values, createJsonPath(path));
|
|
253
|
+
},
|
|
254
|
+
[values],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const onValueChange = useCallback<FormHandler<T>['onValueChange']>(
|
|
258
|
+
(path, type, value) => {
|
|
259
|
+
log('onValueChange', { path, value });
|
|
260
|
+
|
|
261
|
+
const jsonPath = createJsonPath(path);
|
|
262
|
+
let parsedValue = value as any;
|
|
263
|
+
try {
|
|
264
|
+
if (type._tag === 'NumberKeyword') {
|
|
265
|
+
parsedValue = parseFloat(value as string) || 0;
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
log.catch(err);
|
|
269
|
+
parsedValue = undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Update.
|
|
273
|
+
// For ECHO objects, mutate in place via Obj.change(); for plain objects, spread to create new reference.
|
|
274
|
+
const newValues = Obj.isObject(values)
|
|
275
|
+
? (setValueEchoAware(values, jsonPath, parsedValue), values)
|
|
276
|
+
: { ...setValue$(values, jsonPath, parsedValue) };
|
|
277
|
+
setValues(newValues);
|
|
278
|
+
|
|
279
|
+
// TODO(burdon): Check value has changed from original.
|
|
280
|
+
const newChanged = { [jsonPath]: true };
|
|
281
|
+
setChanged({ ...changed, ...newChanged });
|
|
282
|
+
|
|
283
|
+
// Validate.
|
|
284
|
+
const isValid = validate(newValues);
|
|
285
|
+
|
|
286
|
+
// Callback.
|
|
287
|
+
onValuesChanged?.(newValues, { isValid, changed: newChanged });
|
|
288
|
+
},
|
|
289
|
+
[values, changed, validate, onValuesChanged],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const onBlur = useCallback(
|
|
293
|
+
async (path: (string | number)[]) => {
|
|
294
|
+
const jsonPath = createJsonPath(path);
|
|
295
|
+
|
|
296
|
+
// TODO(burdon): Check value has changed from original.
|
|
297
|
+
setTouched((touched) => ({ ...touched, [jsonPath]: true }));
|
|
298
|
+
const isValid = validate(values);
|
|
299
|
+
|
|
300
|
+
// Auto-save when a field is blurred and is valid.
|
|
301
|
+
if (Object.keys(changed).length > 0 && isValid && autoSave) {
|
|
302
|
+
await onSave?.(values as T, { changed, isValid });
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
[validate, values, changed, autoSave, onSave],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return useMemo<FormHandler<T>>(
|
|
309
|
+
() => ({
|
|
310
|
+
// State.
|
|
311
|
+
schema,
|
|
312
|
+
values,
|
|
313
|
+
errors,
|
|
314
|
+
touched,
|
|
315
|
+
changed,
|
|
316
|
+
isValid,
|
|
317
|
+
canSave,
|
|
318
|
+
autoSave,
|
|
319
|
+
|
|
320
|
+
// Actions.
|
|
321
|
+
onSave: handleSave,
|
|
322
|
+
onCancel: handleCancel,
|
|
323
|
+
|
|
324
|
+
// Field utils.
|
|
325
|
+
getStatus,
|
|
326
|
+
getValue,
|
|
327
|
+
onBlur,
|
|
328
|
+
onValueChange,
|
|
329
|
+
|
|
330
|
+
...props,
|
|
331
|
+
}),
|
|
332
|
+
[
|
|
333
|
+
schema,
|
|
334
|
+
values,
|
|
335
|
+
errors,
|
|
336
|
+
touched,
|
|
337
|
+
changed,
|
|
338
|
+
canSave,
|
|
339
|
+
isValid,
|
|
340
|
+
getStatus,
|
|
341
|
+
getValue,
|
|
342
|
+
onBlur,
|
|
343
|
+
onValueChange,
|
|
344
|
+
onSave,
|
|
345
|
+
onCancel,
|
|
346
|
+
],
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const flatMap = (errors: ValidationError[]) => {
|
|
351
|
+
return errors.reduce(
|
|
352
|
+
(result, { path, message }) => {
|
|
353
|
+
// Convert the validation error path format to our JsonPath format.
|
|
354
|
+
const jsonPath = fromEffectValidationPath(path);
|
|
355
|
+
if (!(jsonPath in result)) {
|
|
356
|
+
result[jsonPath] = message;
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
},
|
|
360
|
+
{} as Record<JsonPath, string>,
|
|
361
|
+
);
|
|
362
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect } 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 = (formElement: 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
|
+
useEffect(() => {
|
|
30
|
+
if (!formElement) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TODO(burdon): Move to @dxos/dom-util.
|
|
35
|
+
return addEventListener(formElement, 'keydown', handleKeyDown);
|
|
36
|
+
}, [formElement, handleKeyDown]);
|
|
37
|
+
};
|
package/src/translations.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
{
|
|
@@ -15,7 +15,7 @@ export const translations = [
|
|
|
15
15
|
'empty readonly ref field label': '(none)',
|
|
16
16
|
|
|
17
17
|
// TODO(burdon): Standardize field/property.
|
|
18
|
-
'
|
|
18
|
+
'add property button label': 'Add property',
|
|
19
19
|
'field property label': 'Property',
|
|
20
20
|
'field property placeholder': 'Property name',
|
|
21
21
|
'field format label': 'Type',
|
|
@@ -25,10 +25,19 @@ export const translations = [
|
|
|
25
25
|
'field limit reached': 'Maximum number of fields reached',
|
|
26
26
|
'boolean input true value': 'Yes',
|
|
27
27
|
'boolean input false value': 'No',
|
|
28
|
+
'show field label': 'Show field',
|
|
29
|
+
'hide field label': 'Hide field',
|
|
30
|
+
'delete field label': 'Delete field',
|
|
31
|
+
'ref field combobox input placeholder': 'Search…',
|
|
32
|
+
'ref field placeholder_one': 'Select…',
|
|
33
|
+
'ref field placeholder_other': 'Select items…',
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
'
|
|
35
|
+
'example placeholder': 'Example',
|
|
36
|
+
'latitude placeholder': 'Latitude (e.g., 40.7128)',
|
|
37
|
+
'longitude placeholder': 'Longitude (e.g., -74.0060)',
|
|
38
|
+
|
|
39
|
+
'cancel button label': 'Cancel',
|
|
40
|
+
'save button label': 'Save',
|
|
32
41
|
|
|
33
42
|
// FormatEnum
|
|
34
43
|
'format boolean': 'Boolean',
|
|
@@ -43,7 +52,7 @@ export const translations = [
|
|
|
43
52
|
'format hostname': 'Hostname',
|
|
44
53
|
'format integer': 'Integer',
|
|
45
54
|
'format json': 'JSON',
|
|
46
|
-
'format
|
|
55
|
+
'format lnglat': 'Geopoint',
|
|
47
56
|
'format markdown': 'Markdown',
|
|
48
57
|
'format percent': 'Percent',
|
|
49
58
|
'format ref': 'Reference',
|
|
@@ -60,11 +69,11 @@ export const translations = [
|
|
|
60
69
|
'format url': 'URL',
|
|
61
70
|
'format uuid': 'UUID',
|
|
62
71
|
|
|
63
|
-
//
|
|
64
|
-
'
|
|
65
|
-
'
|
|
72
|
+
// GeoPointField.
|
|
73
|
+
'latitude label': 'Latitude',
|
|
74
|
+
'longitude label': 'Longitude',
|
|
66
75
|
|
|
67
|
-
//
|
|
76
|
+
// SelectOptionsField.
|
|
68
77
|
'select option label': 'Label',
|
|
69
78
|
'select option label placeholder': 'Option label',
|
|
70
79
|
'select option color': 'Color',
|
|
@@ -72,8 +81,8 @@ export const translations = [
|
|
|
72
81
|
'select option add': 'Add option',
|
|
73
82
|
|
|
74
83
|
// System schema message.
|
|
75
|
-
'system schema title': 'System
|
|
76
|
-
'system schema description': '
|
|
84
|
+
'system schema title': 'System type',
|
|
85
|
+
'system schema description': 'System type: cannot add or remove fields.',
|
|
77
86
|
},
|
|
78
87
|
},
|
|
79
88
|
},
|
package/src/util/echo.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Obj } from '@dxos/echo';
|
|
6
|
+
import { type JsonPath, setValue as setValuePlain, splitJsonPath } from '@dxos/effect';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sets a value on an object, using Obj.change() if it's an ECHO object.
|
|
10
|
+
*/
|
|
11
|
+
// TODO(wittjosiah): Consider refactoring Form to not know about ECHO at all.
|
|
12
|
+
// Instead of directly mutating the values object, Form could work with a plain
|
|
13
|
+
// object copy and communicate all mutations through callbacks (e.g., onValuesChanged).
|
|
14
|
+
// The caller would then be responsible for applying changes to ECHO objects via
|
|
15
|
+
// Obj.change(). This would keep Form agnostic to the underlying data layer and
|
|
16
|
+
// avoid coupling react-ui-form to @dxos/echo.
|
|
17
|
+
export const setValueEchoAware = <T extends object>(obj: T, path: JsonPath, value: any): T => {
|
|
18
|
+
if (Obj.isObject(obj)) {
|
|
19
|
+
Obj.change(obj, (mutableObj) => {
|
|
20
|
+
Obj.setValue(mutableObj, splitJsonPath(path), value);
|
|
21
|
+
});
|
|
22
|
+
return obj;
|
|
23
|
+
}
|
|
24
|
+
return setValuePlain(obj, path, value);
|
|
25
|
+
};
|
package/src/util/field.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Format, GeoLocation, TypeEnum } from '@dxos/echo/internal';
|
|
6
6
|
import { type ValidationError } from '@dxos/schema';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -13,7 +13,7 @@ import { type ValidationError } from '@dxos/schema';
|
|
|
13
13
|
*/
|
|
14
14
|
export type ParseProps = {
|
|
15
15
|
type?: TypeEnum;
|
|
16
|
-
format?:
|
|
16
|
+
format?: Format.TypeFormat;
|
|
17
17
|
value: any;
|
|
18
18
|
};
|
|
19
19
|
|
|
@@ -57,31 +57,31 @@ export const parseValue = ({ type, format, value }: ParseProps) => {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
switch (format) {
|
|
60
|
-
case
|
|
60
|
+
case Format.TypeFormat.Boolean:
|
|
61
61
|
return parseScalar(TypeEnum.Boolean);
|
|
62
62
|
|
|
63
|
-
case
|
|
64
|
-
case
|
|
65
|
-
case
|
|
63
|
+
case Format.TypeFormat.Number:
|
|
64
|
+
case Format.TypeFormat.Percent:
|
|
65
|
+
case Format.TypeFormat.Currency:
|
|
66
66
|
return parseScalar(TypeEnum.Number);
|
|
67
67
|
|
|
68
|
-
case
|
|
69
|
-
case
|
|
68
|
+
case Format.TypeFormat.String:
|
|
69
|
+
case Format.TypeFormat.Markdown: {
|
|
70
70
|
return parseScalar(TypeEnum.String);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
case
|
|
74
|
-
throw new Error(`unexpected format: ${
|
|
73
|
+
case Format.TypeFormat.Ref:
|
|
74
|
+
throw new Error(`unexpected format: ${Format.TypeFormat.Ref}`);
|
|
75
75
|
|
|
76
|
-
case
|
|
77
|
-
case
|
|
78
|
-
case
|
|
79
|
-
case
|
|
76
|
+
case Format.TypeFormat.DateTime:
|
|
77
|
+
case Format.TypeFormat.Date:
|
|
78
|
+
case Format.TypeFormat.Time:
|
|
79
|
+
case Format.TypeFormat.Timestamp: {
|
|
80
80
|
const date = new Date(value as string | number);
|
|
81
81
|
return isNaN(date.getTime()) ? null : date;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
case
|
|
84
|
+
case Format.TypeFormat.GeoPoint: {
|
|
85
85
|
// Parse string in format "lat,long" to GeoPoint [longitude, latitude].
|
|
86
86
|
if (typeof value === 'string') {
|
|
87
87
|
// Only keep digits, decimal points, minus signs, and commas.
|
|
@@ -108,41 +108,31 @@ export const parseValue = ({ type, format, value }: ParseProps) => {
|
|
|
108
108
|
// TODO(burdon): Type and format.
|
|
109
109
|
export type CellClassesForFieldTypeProps = {
|
|
110
110
|
type?: TypeEnum;
|
|
111
|
-
format?:
|
|
111
|
+
format?: Format.TypeFormat;
|
|
112
112
|
};
|
|
113
113
|
|
|
114
114
|
export const cellClassesForFieldType = ({ type, format }: CellClassesForFieldTypeProps): string[] | undefined => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
case TypeEnum.Number:
|
|
118
|
-
return ['text-right', 'font-mono'];
|
|
119
|
-
case TypeEnum.Boolean:
|
|
120
|
-
return ['text-right', 'font-mono'];
|
|
121
|
-
case TypeEnum.String:
|
|
122
|
-
return undefined;
|
|
123
|
-
case TypeEnum.Ref:
|
|
124
|
-
return undefined;
|
|
125
|
-
|
|
126
|
-
default: {
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
switch (format) {
|
|
133
|
-
case FormatEnum.Markdown:
|
|
115
|
+
switch (format || type) {
|
|
116
|
+
case Format.TypeFormat.Markdown:
|
|
134
117
|
return undefined;
|
|
135
|
-
case
|
|
136
|
-
case
|
|
137
|
-
case
|
|
138
|
-
case
|
|
139
|
-
case
|
|
118
|
+
case Format.TypeFormat.Time:
|
|
119
|
+
case Format.TypeFormat.Timestamp:
|
|
120
|
+
case Format.TypeFormat.DateTime:
|
|
121
|
+
case Format.TypeFormat.Date:
|
|
122
|
+
case Format.TypeFormat.Duration:
|
|
140
123
|
return ['font-mono', 'text-right'];
|
|
141
|
-
case
|
|
124
|
+
case Format.TypeFormat.Currency:
|
|
142
125
|
return ['text-right'];
|
|
143
|
-
case
|
|
144
|
-
case
|
|
126
|
+
case Format.TypeFormat.JSON:
|
|
127
|
+
case Format.TypeFormat.DID:
|
|
145
128
|
return ['font-mono'];
|
|
129
|
+
case TypeEnum.Number:
|
|
130
|
+
case TypeEnum.Boolean:
|
|
131
|
+
return ['text-right', 'font-mono'];
|
|
132
|
+
case TypeEnum.String:
|
|
133
|
+
return undefined;
|
|
134
|
+
case TypeEnum.Ref:
|
|
135
|
+
return undefined;
|
|
146
136
|
default:
|
|
147
137
|
return undefined;
|
|
148
138
|
}
|