mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Form lib] Improve code readability and comments (#126340)
This commit is contained in:
parent
73e4069bed
commit
bc19e0dd0d
20 changed files with 819 additions and 374 deletions
68
src/plugins/es_ui_shared/static/forms/README.md
Normal file
68
src/plugins/es_ui_shared/static/forms/README.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Form lib
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation can be accessed at: https://docs.elastic.dev/form-lib/welcome
|
||||
|
||||
|
||||
### Run locally
|
||||
|
||||
In order to run the documentation locally
|
||||
|
||||
1. Fork and clone the elastic docs repo https://github.com/elastic/docs.elastic.dev
|
||||
2. `cp sources.json sources-dev.json`
|
||||
3. Edit the "elastic/kibana" section inside `source-dev.json`
|
||||
|
||||
```
|
||||
// From this
|
||||
{
|
||||
"type": "github",
|
||||
"location": "elastic/kibana"
|
||||
}
|
||||
|
||||
// to this
|
||||
{
|
||||
"type": "file",
|
||||
"location": "../../<root-kibana-repo>",
|
||||
// optional, if you want a faster build you can only include the form lib docs
|
||||
"subdirs": [
|
||||
"src/plugins/es_ui_shared/static/forms/docs"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. Follow the "Getting started" instructions (https://github.com/elastic/docs.elastic.dev#getting-started)
|
||||
5. `yarn dev` to launch the docs server
|
||||
|
||||
## Field value change sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
User ->> UseField: change <input /> value
|
||||
UseField ->> useField: setValue()
|
||||
useField ->> useField: run field formatters
|
||||
useField ->> useField: update state: value
|
||||
useField ->> useField: create new "field" (FieldHook)
|
||||
useField ->> useField: useEffect(): "field" changed
|
||||
useField ->> useForm: addField(path, field)
|
||||
useForm ->> useForm: update "fieldsRef" map object
|
||||
useForm ->> useForm: update "formData$" observable
|
||||
useForm ->> useFormData: update state: formData
|
||||
useFormData -->> User: onChange() (optional handler passed to useFormdata())
|
||||
par useEffect
|
||||
useField ->> UseField: call "onChange" prop
|
||||
and useEffect
|
||||
useField ->> useField: update state: isPristine: false
|
||||
useField ->> useField: update state: isChangingValue: true
|
||||
useField ->> useForm: validateFields()
|
||||
note right of useForm: Validate the current field + any other field<br>declared in the "fieldsToValidateOnChange"
|
||||
useForm -->> useField: validate()
|
||||
useField ->> useField: update state: isValid true|false
|
||||
useField ->> useForm: update state: isValid undefined|true|false
|
||||
useField ->> useField: update state: isChangingValue: false
|
||||
and useEffect
|
||||
useField ->> useField: update state: isModified: true|false
|
||||
end
|
||||
User ->> User: useEffect() -- >useFormData state update
|
||||
```
|
Binary file not shown.
After Width: | Height: | Size: 787 KiB |
|
@ -4,7 +4,7 @@ slug: /form-lib/core/fundamentals
|
|||
title: Fundamentals
|
||||
summary: Let's understand the basics
|
||||
tags: ['forms', 'kibana', 'dev']
|
||||
date: 2021-04-14
|
||||
date: 2022-03-18
|
||||
---
|
||||
|
||||
The core exposes the main building blocks (hooks and components) needed to build your form.
|
||||
|
@ -79,3 +79,11 @@ export const UserForm = () => {
|
|||
Great! We have our first working form. No state to worry about, just a simple declarative way to build our fields.
|
||||
|
||||
Those of you who are attentive might have noticed that the above form _does_ render the fields in the UI although we said earlier that the core of the form lib is not responsible for any UI rendering. This is because the `<UseField />` has a fallback mechanism to render an `<input type="text" />` and hook to the field `value` and `onChange`. Unless you have styled your `input` elements and don't require other field types like `checkbox` or `select`, you will probably want to <DocLink id="formLibExampleStyleFields" text="customize"/> how the the `<UseField />` renders. We will see that in a future section.
|
||||
|
||||
## Field value change sequence diagram
|
||||
|
||||
{/*The elastic docs system does not support yet rendering "mermaid" diagram
|
||||
in .mdx I've asked the docs engineers how to enable it and will follow up on it.
|
||||
In the meantime we will use the Whimsical exported image.*/}
|
||||
|
||||

|
|
@ -4,7 +4,7 @@ slug: /form-lib/core/use-form-is-modified
|
|||
title: useFormIsModified()
|
||||
summary: Know when your form has been modified by the user
|
||||
tags: ['forms', 'kibana', 'dev']
|
||||
date: 2021-06-15
|
||||
date: 2022-03-18
|
||||
---
|
||||
|
||||
**Returns:** `boolean`
|
||||
|
@ -49,3 +49,5 @@ const ChildComponent = () => {
|
|||
**Type:** `string[]`
|
||||
|
||||
If there are certain fields that you want to discard when checking if the form has been modified you can provide an array of field paths to the `discard` option.
|
||||
|
||||
This is useful if you add some fields to the form that are only used internally to control the UI (e.g. toggles) that shouldn't affect the `isModified` state.
|
||||
|
|
|
@ -360,24 +360,33 @@ const schema = {
|
|||
import { firstValueFrom } from '@kbn/std';
|
||||
|
||||
const MyForm = () => {
|
||||
...
|
||||
const { form } = useForm();
|
||||
const [indices, setIndices] = useState([]);
|
||||
const [indices$, nextIndices] = useBehaviorSubject(null); // Use the provided util hook to create an observable
|
||||
|
||||
const onIndexNameChange = useCallback(({ indexName }) => {
|
||||
// Whenever the indexName changes reset the subject to not send stale data to the validator
|
||||
nextIndices(null);
|
||||
}, []);
|
||||
|
||||
const [{ indexName }] = useFormData({
|
||||
form,
|
||||
watch: 'indexName',
|
||||
onChange: onIndexNameChange, // React to changes before any validation is executed
|
||||
});
|
||||
|
||||
const indicesProvider = useCallback(() => {
|
||||
// We wait until we have fetched the indices.
|
||||
// The result will then be sent to the validator (await provider() call);
|
||||
// Wait until we have fetched the indices.
|
||||
// The result will then be sent to the field validator(s) (when calling await provider(););
|
||||
return await firstValueFrom(indices$.pipe(first((data) => data !== null)));
|
||||
}, [indices$, nextIndices]);
|
||||
|
||||
const fetchIndices = useCallback(async () => {
|
||||
// Reset the subject to not send stale data to the validator
|
||||
nextIndices(null);
|
||||
|
||||
const result = await httpClient.get(`/api/search/${indexName}`);
|
||||
// update the component state
|
||||
setIndices(result);
|
||||
|
||||
// Send the indices to the BehaviorSubject to resolve the validator "provider()"
|
||||
// Send the indices to the BehaviorSubject to resolve the "indicesProvider()" promise
|
||||
nextIndices(result);
|
||||
}, [indexName]);
|
||||
|
||||
|
@ -400,23 +409,3 @@ const MyForm = () => {
|
|||
```
|
||||
|
||||
Et voilà! We have provided dynamic data asynchronously to our validator.
|
||||
|
||||
The above example could be simplified a bit by using the optional `state` argument of the `useAsyncValidationData(/* state */)` hook.
|
||||
|
||||
```js
|
||||
const MyForm = () => {
|
||||
...
|
||||
const [indices, setIndices] = useState([]);
|
||||
// We don't need the second element of the array (the "nextIndices()" handler)
|
||||
// as whenever the "indices" state changes the "indices$" Observable will receive its value
|
||||
const [indices$] = useAsyncValidationData(indices);
|
||||
|
||||
...
|
||||
|
||||
const fetchIndices = useCallback(async () => {
|
||||
const result = await httpClient.get(`/api/search/${indexName}`);
|
||||
setIndices(result); // This will also update the Observable
|
||||
}, [indexName]);
|
||||
|
||||
...
|
||||
```
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"mission": "ES UI: Form lib",
|
||||
"id": "formLib",
|
||||
"landingPageId": "formLibWelcome",
|
||||
"icon": "indexEdit",
|
||||
"description": "Build complex forms the easy way",
|
||||
"items": [
|
||||
{
|
||||
"label": "Getting started",
|
||||
"items": [{ "id": "formLibWelcome" }]
|
||||
},
|
||||
{
|
||||
"label": "Examples",
|
||||
"items": [
|
||||
{ "id": "formLibExampleStyleFields" },
|
||||
{ "id": "formLibExampleValidation" },
|
||||
{ "id": "formLibExampleListeningToChanges" },
|
||||
{ "id": "formLibExampleFieldsComposition" },
|
||||
{ "id": "formLibExampleDynamicFields" },
|
||||
{ "id": "formLibExampleSerializersDeserializers" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Core",
|
||||
"items": [
|
||||
{ "id": "formLibCoreFundamentals" },
|
||||
{ "id": "formLibCoreDefaultValue" },
|
||||
{ "id": "formLibCoreUseForm" },
|
||||
{ "id": "formLibCoreFormHook" },
|
||||
{ "id": "formLibCoreFormComponent" },
|
||||
{ "id": "formLibCoreUseField" },
|
||||
{ "id": "formLibCoreFieldHook" },
|
||||
{ "id": "formLibCoreUseMultiFields" },
|
||||
{ "id": "formLibCoreUseArray" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hooks",
|
||||
"items": [
|
||||
{ "id": "formLibCoreUseFormData" },
|
||||
{ "id": "formLibCoreUseFormIsModified" },
|
||||
{ "id": "formLibCoreUseBehaviorSubject" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Helpers",
|
||||
"items": [
|
||||
{ "id": "formLibHelpersComponents" },
|
||||
{ "id": "formLibHelpersValidators" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -20,14 +20,17 @@ const FormDataProviderComp = function <I extends FormData = FormData>({
|
|||
children,
|
||||
pathsToWatch,
|
||||
}: Props<I>) {
|
||||
const { 0: formData, 2: isReady } = useFormData<I>({ watch: pathsToWatch });
|
||||
const { 0: formData, 2: haveFieldsMounted } = useFormData<I>({ watch: pathsToWatch });
|
||||
|
||||
if (!isReady) {
|
||||
// No field has mounted yet, don't render anything
|
||||
if (!haveFieldsMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children(formData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Context provider to access the form data.
|
||||
* @deprecated Use the "useFormData()" hook instead
|
||||
*/
|
||||
export const FormDataProvider = React.memo(FormDataProviderComp) as typeof FormDataProviderComp;
|
||||
|
|
|
@ -6,8 +6,16 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './form';
|
||||
export * from './use_field';
|
||||
export * from './use_multi_fields';
|
||||
export * from './use_array';
|
||||
export * from './form_data_provider';
|
||||
export { Form } from './form';
|
||||
|
||||
export { UseField, getUseField } from './use_field';
|
||||
|
||||
export type { Props as UseFieldProps } from './use_field';
|
||||
|
||||
export { UseMultiFields } from './use_multi_fields';
|
||||
|
||||
export { UseArray } from './use_array';
|
||||
|
||||
export type { ArrayItem, FormArrayField } from './use_array';
|
||||
|
||||
export { FormDataProvider } from './form_data_provider';
|
||||
|
|
|
@ -46,7 +46,7 @@ export interface FormArrayField {
|
|||
* users: []
|
||||
* }
|
||||
*
|
||||
* and you want to be able to add user objects ({ name: 'john', lastName. 'snow' }) inside
|
||||
* and you want to be able to add user objects (e.g. { name: 'john', lastName. 'snow' }) inside
|
||||
* the "users" array, you would use UseArray to render rows of user objects with 2 fields in each of them ("name" and "lastName")
|
||||
*
|
||||
* Look at the README.md for some examples.
|
||||
|
@ -75,27 +75,28 @@ export const UseArray = ({
|
|||
|
||||
const fieldDefaultValue = useMemo<ArrayItem[]>(() => {
|
||||
const defaultValues = readDefaultValueOnForm
|
||||
? (getFieldDefaultValue(path) as any[])
|
||||
? getFieldDefaultValue<unknown[]>(path)
|
||||
: undefined;
|
||||
|
||||
const getInitialItemsFromValues = (values: any[]): ArrayItem[] =>
|
||||
values.map((_, index) => ({
|
||||
if (defaultValues) {
|
||||
return defaultValues.map((_, index) => ({
|
||||
id: uniqueId.current++,
|
||||
path: `${path}[${index}]`,
|
||||
isNew: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return defaultValues
|
||||
? getInitialItemsFromValues(defaultValues)
|
||||
: new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i));
|
||||
return new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i));
|
||||
}, [path, initialNumberOfItems, readDefaultValueOnForm, getFieldDefaultValue, getNewItemAtIndex]);
|
||||
|
||||
// Create a new hook field with the "isIncludedInOutput" set to false so we don't use its value to build the final form data.
|
||||
// Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle.
|
||||
// Create an internal hook field which behaves like any other form field except that it is not
|
||||
// outputed in the form data (when calling form.submit() or form.getFormData())
|
||||
// This allow us to run custom validations (passed to the props) on the Array items
|
||||
const fieldConfigBase: FieldConfig<ArrayItem[]> & InternalFieldConfig<ArrayItem[]> = {
|
||||
defaultValue: fieldDefaultValue,
|
||||
initialValue: fieldDefaultValue,
|
||||
valueChangeDebounceTime: 0,
|
||||
isIncludedInOutput: false,
|
||||
isIncludedInOutput: false, // indicate to not include this field when returning the form data
|
||||
};
|
||||
|
||||
const fieldConfig: FieldConfig<ArrayItem[]> & InternalFieldConfig<ArrayItem[]> = validations
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import React, { FunctionComponent, useMemo, useEffect } from 'react';
|
||||
|
||||
import { FieldHook, FieldConfig, FormData } from '../types';
|
||||
import { useField } from '../hooks';
|
||||
import { FieldHook, FieldConfig, FormData, FieldValidationData } from '../types';
|
||||
import { useField, InternalFieldConfig } from '../hooks';
|
||||
import { useFormContext } from '../form_context';
|
||||
|
||||
export interface Props<T, FormType = FormData, I = T> {
|
||||
|
@ -49,9 +49,12 @@ export interface Props<T, FormType = FormData, I = T> {
|
|||
}
|
||||
|
||||
function UseFieldComp<T = unknown, FormType = FormData, I = T>(props: Props<T, FormType, I>) {
|
||||
const form = useFormContext<FormType>();
|
||||
const { getFieldDefaultValue, __readFieldConfigFromSchema, __updateDefaultValueAt } = form;
|
||||
|
||||
const {
|
||||
path,
|
||||
config,
|
||||
config = __readFieldConfigFromSchema<T, FormType, I>(props.path),
|
||||
defaultValue,
|
||||
component,
|
||||
componentProps,
|
||||
|
@ -59,43 +62,82 @@ function UseFieldComp<T = unknown, FormType = FormData, I = T>(props: Props<T, F
|
|||
onChange,
|
||||
onError,
|
||||
children,
|
||||
validationData: customValidationData,
|
||||
validationDataProvider: customValidationDataProvider,
|
||||
validationData,
|
||||
validationDataProvider,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const form = useFormContext<FormType>();
|
||||
const ComponentToRender = component ?? 'input';
|
||||
const propsToForward = { ...componentProps, ...rest };
|
||||
|
||||
const fieldConfig: FieldConfig<T, FormType, I> & { initialValue?: T } =
|
||||
config !== undefined
|
||||
? { ...config }
|
||||
: ({
|
||||
...form.__readFieldConfigFromSchema(path),
|
||||
} as Partial<FieldConfig<T, FormType, I>>);
|
||||
const initialValue = useMemo<T>(() => {
|
||||
// The initial value of the field.
|
||||
// Order in which we'll determine this value:
|
||||
// 1. The "defaultValue" passed through prop
|
||||
// --> <UseField path="foo" defaultValue="bar" />
|
||||
// 2. A value declared in the "defaultValue" object passed to the form when initiating
|
||||
// --> const { form } = useForm({ defaultValue: { foo: 'bar' } }))
|
||||
// 3. The "defaultValue" declared on the field "config". Either passed through prop or on the form schema
|
||||
// a. --> <UseField path="foo" config={{ defaultValue: 'bar' }} />
|
||||
// b. --> const formSchema = { foo: { defaultValue: 'bar' } }
|
||||
// 4. An empty string ("")
|
||||
|
||||
if (defaultValue !== undefined) {
|
||||
// update the form "defaultValue" ref object so when/if we reset the form we can go back to this value
|
||||
form.__updateDefaultValueAt(path, defaultValue);
|
||||
|
||||
// Use the defaultValue prop as initial value
|
||||
fieldConfig.initialValue = defaultValue;
|
||||
} else {
|
||||
if (readDefaultValueOnForm) {
|
||||
// Read the field initial value from the "defaultValue" object passed to the form
|
||||
fieldConfig.initialValue = (form.getFieldDefaultValue(path) as T) ?? fieldConfig.defaultValue;
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue; // defaultValue passed through props
|
||||
}
|
||||
}
|
||||
|
||||
const field = useField<T, FormType, I>(form, path, fieldConfig, onChange, onError, {
|
||||
customValidationData,
|
||||
customValidationDataProvider,
|
||||
});
|
||||
let value: T | undefined;
|
||||
|
||||
if (readDefaultValueOnForm) {
|
||||
// Check the "defaultValue" object passed to the form
|
||||
value = getFieldDefaultValue<T>(path);
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
// Check the field "config" object (passed through prop or declared on the form schema)
|
||||
value = config?.defaultValue;
|
||||
}
|
||||
|
||||
// If still undefined return an empty string
|
||||
return value === undefined ? ('' as unknown as T) : value;
|
||||
}, [defaultValue, path, config, readDefaultValueOnForm, getFieldDefaultValue]);
|
||||
|
||||
const fieldConfig = useMemo<FieldConfig<T, FormType, I> & InternalFieldConfig<T>>(
|
||||
() => ({
|
||||
...config,
|
||||
initialValue,
|
||||
}),
|
||||
[config, initialValue]
|
||||
);
|
||||
|
||||
const fieldValidationData = useMemo<FieldValidationData>(
|
||||
() => ({
|
||||
validationData,
|
||||
validationDataProvider,
|
||||
}),
|
||||
[validationData, validationDataProvider]
|
||||
);
|
||||
|
||||
const field = useField<T, FormType, I>(
|
||||
form,
|
||||
path,
|
||||
fieldConfig,
|
||||
onChange,
|
||||
onError,
|
||||
fieldValidationData
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue !== undefined) {
|
||||
// Update the form "defaultValue" ref object.
|
||||
// This allows us to reset the form and put back the defaultValue of each field
|
||||
__updateDefaultValueAt(path, defaultValue);
|
||||
}
|
||||
}, [path, defaultValue, __updateDefaultValueAt]);
|
||||
|
||||
// Children prevails over anything else provided.
|
||||
if (children) {
|
||||
return children!(field);
|
||||
return children(field);
|
||||
}
|
||||
|
||||
if (ComponentToRender === 'input') {
|
||||
|
@ -117,6 +159,18 @@ export const UseField = React.memo(UseFieldComp) as typeof UseFieldComp;
|
|||
/**
|
||||
* Get a <UseField /> component providing some common props for all instances.
|
||||
* @param partialProps Partial props to apply to all <UseField /> instances
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* // All the "MyUseField" are TextFields
|
||||
* const MyUseField = getUseField({ component: TextField });
|
||||
*
|
||||
* // JSX
|
||||
* <Form>
|
||||
* <MyUseField path="textField_0" />
|
||||
* <MyUseField path="textField_1" />
|
||||
* <MyUseField path="textField_2" />
|
||||
* </Form>
|
||||
*/
|
||||
export function getUseField<T1 = unknown, FormType1 = FormData, I1 = T1>(
|
||||
partialProps: Partial<Props<T1, FormType1, I1>>
|
||||
|
|
|
@ -18,6 +18,64 @@ interface Props<T> {
|
|||
children: (fields: { [K in keyof T]: FieldHook<T[K]> }) => JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this component to avoid nesting multiple <UseField />
|
||||
@example
|
||||
```
|
||||
// before
|
||||
<UseField path="maxValue">
|
||||
{maxValueField => {
|
||||
return (
|
||||
<UseField path="minValue">
|
||||
{minValueField => {
|
||||
return (
|
||||
// The EuiDualRange handles 2 values (min and max) and thus
|
||||
// updates 2 fields in our form
|
||||
<EuiDualRange
|
||||
min={0}
|
||||
max={100}
|
||||
value={[minValueField.value, maxValueField.value]}
|
||||
onChange={([minValue, maxValue]) => {
|
||||
minValueField.setValue(minValue);
|
||||
maxValueField.setValue(maxValue);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</UseField>
|
||||
)
|
||||
}}
|
||||
</UseField>
|
||||
|
||||
// after
|
||||
const fields = {
|
||||
min: {
|
||||
... // any prop you would normally pass to <UseField />
|
||||
path: 'minValue',
|
||||
config: { ... } // FieldConfig
|
||||
},
|
||||
max: {
|
||||
path: 'maxValue',
|
||||
},
|
||||
};
|
||||
|
||||
<UseMultiField fields={fields}>
|
||||
{({ min, max }) => {
|
||||
return (
|
||||
<EuiDualRange
|
||||
min={0}
|
||||
max={100}
|
||||
value={[min.value, max.value]}
|
||||
onChange={([minValue, maxValue]) => {
|
||||
min.setValue(minValue);
|
||||
max.setValue(maxValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseMultiField>
|
||||
```
|
||||
*/
|
||||
export function UseMultiFields<T = { [key: string]: unknown }>({ fields, children }: Props<T>) {
|
||||
const fieldsArray = Object.entries(fields).reduce(
|
||||
(acc, [fieldId, field]) => [...acc, { id: fieldId, ...(field as FieldHook) }],
|
||||
|
|
|
@ -16,12 +16,19 @@ export interface Context<T extends FormData = FormData, I extends FormData = T>
|
|||
getFormData: FormHook<T, I>['getFormData'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context required for the "useFormData()" hook in order to access the form data
|
||||
* observable and the getFormData() handler which serializes the form data
|
||||
*/
|
||||
const FormDataContext = createContext<Context<any> | undefined>(undefined);
|
||||
|
||||
interface Props extends Context {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* This provider wraps the whole form and is consumed by the <Form /> component
|
||||
*/
|
||||
export const FormDataContextProvider = ({ children, getFormData$, getFormData }: Props) => {
|
||||
const value = useMemo<Context>(
|
||||
() => ({
|
||||
|
|
|
@ -16,32 +16,31 @@ import {
|
|||
ValidationError,
|
||||
FormData,
|
||||
ValidationConfig,
|
||||
FieldValidationData,
|
||||
ValidationCancelablePromise,
|
||||
} from '../types';
|
||||
import { FIELD_TYPES, VALIDATION_TYPES } from '../constants';
|
||||
|
||||
export interface InternalFieldConfig<T> {
|
||||
initialValue?: T;
|
||||
initialValue: T;
|
||||
isIncludedInOutput?: boolean;
|
||||
}
|
||||
|
||||
export const useField = <T, FormType = FormData, I = T>(
|
||||
form: FormHook<FormType>,
|
||||
path: string,
|
||||
config: FieldConfig<T, FormType, I> & InternalFieldConfig<T> = {},
|
||||
config: FieldConfig<T, FormType, I> & InternalFieldConfig<T>,
|
||||
valueChangeListener?: (value: I) => void,
|
||||
errorChangeListener?: (errors: string[] | null) => void,
|
||||
{
|
||||
customValidationData = null,
|
||||
customValidationDataProvider,
|
||||
}: {
|
||||
customValidationData?: unknown;
|
||||
customValidationDataProvider?: () => Promise<unknown>;
|
||||
} = {}
|
||||
validationData = null,
|
||||
validationDataProvider = () => Promise.resolve(undefined),
|
||||
}: FieldValidationData = {}
|
||||
) => {
|
||||
const {
|
||||
type = FIELD_TYPES.TEXT,
|
||||
defaultValue = '', // The value to use a fallback mecanism when no initial value is passed
|
||||
initialValue = config.defaultValue ?? ('' as unknown as I), // The value explicitly passed
|
||||
defaultValue = '' as unknown as T, // The default value instead of "undefined" (when resetting the form this will be the field value)
|
||||
initialValue, // The initial value of the field when rendering the form
|
||||
isIncludedInOutput = true,
|
||||
label = '',
|
||||
labelAppend = '',
|
||||
|
@ -49,32 +48,29 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
validations,
|
||||
formatters,
|
||||
fieldsToValidateOnChange,
|
||||
valueChangeDebounceTime = form.__options.valueChangeDebounceTime,
|
||||
valueChangeDebounceTime = form.__options.valueChangeDebounceTime, // By default 500ms
|
||||
serializer,
|
||||
deserializer,
|
||||
} = config;
|
||||
|
||||
const {
|
||||
getFormData,
|
||||
getFields,
|
||||
__addField,
|
||||
__removeField,
|
||||
__updateFormDataAt,
|
||||
validateFields,
|
||||
__getFormData$,
|
||||
} = form;
|
||||
const { getFormData, getFields, validateFields, __addField, __removeField, __getFormData$ } =
|
||||
form;
|
||||
|
||||
const deserializeValue = useCallback(
|
||||
(rawValue = initialValue) => {
|
||||
(rawValue: T): I => {
|
||||
if (typeof rawValue === 'function') {
|
||||
return deserializer ? deserializer(rawValue()) : rawValue();
|
||||
}
|
||||
return deserializer ? deserializer(rawValue) : rawValue;
|
||||
return deserializer ? deserializer(rawValue) : (rawValue as unknown as I);
|
||||
},
|
||||
[initialValue, deserializer]
|
||||
[deserializer]
|
||||
);
|
||||
|
||||
const [value, setStateValue] = useState<I>(deserializeValue);
|
||||
const initialValueDeserialized = useMemo(() => {
|
||||
return deserializeValue(initialValue);
|
||||
}, [deserializeValue, initialValue]);
|
||||
|
||||
const [value, setStateValue] = useState<I>(initialValueDeserialized);
|
||||
const [errors, setStateErrors] = useState<ValidationError[]>([]);
|
||||
const [isPristine, setPristine] = useState(true);
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
|
@ -86,10 +82,11 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
const validateCounter = useRef(0);
|
||||
const changeCounter = useRef(0);
|
||||
const hasBeenReset = useRef<boolean>(false);
|
||||
const inflightValidation = useRef<(Promise<any> & { cancel?(): void }) | null>(null);
|
||||
const inflightValidation = useRef<ValidationCancelablePromise | null>(null);
|
||||
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
// Keep a ref of the last state (value and errors) notified to the consumer so he does
|
||||
// not get tons of updates whenever he does not wrap the "onChange()" and "onError()" handlers with a useCallback
|
||||
// Keep a ref of the last state (value and errors) notified to the consumer so they don't get
|
||||
// loads of updates whenever they don't wrap the "onChange()" and "onError()" handlers with a useCallback
|
||||
// e.g. <UseField onChange={() => { // inline code }}
|
||||
const lastNotifiedState = useRef<{ value?: I; errors: string[] | null }>({
|
||||
value: undefined,
|
||||
errors: null,
|
||||
|
@ -107,7 +104,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
// -- HELPERS
|
||||
// ----------------------------------
|
||||
/**
|
||||
* Filter an array of errors with specific validation type on them
|
||||
* Filter an array of errors for a specific validation type
|
||||
*
|
||||
* @param _errors The array of errors to filter
|
||||
* @param validationType The validation type to filter out
|
||||
|
@ -131,67 +128,62 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
* updating the "value" state.
|
||||
*/
|
||||
const formatInputValue = useCallback(
|
||||
<U>(inputValue: unknown): U => {
|
||||
(inputValue: unknown): I => {
|
||||
const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === '';
|
||||
|
||||
if (isEmptyString || !formatters) {
|
||||
return inputValue as U;
|
||||
return inputValue as I;
|
||||
}
|
||||
|
||||
const formData = __getFormData$().value;
|
||||
|
||||
return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as U;
|
||||
return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as I;
|
||||
},
|
||||
[formatters, __getFormData$]
|
||||
);
|
||||
|
||||
const onValueChange = useCallback(async () => {
|
||||
const changeIteration = ++changeCounter.current;
|
||||
const startTime = Date.now();
|
||||
const runValidationsOnValueChange = useCallback(
|
||||
async (done: () => void) => {
|
||||
const changeIteration = ++changeCounter.current;
|
||||
const startTime = Date.now();
|
||||
|
||||
setPristine(false);
|
||||
setIsChangingValue(true);
|
||||
// We call "validateFields" on the form which in turn will call
|
||||
// our "validate()" function here below.
|
||||
// The form is the coordinator and has access to all of the fields. We can
|
||||
// this way validate multiple field whenever one field value changes.
|
||||
await validateFields(fieldsToValidateOnChange ?? [path]);
|
||||
|
||||
// Update the form data observable
|
||||
__updateFormDataAt(path, value);
|
||||
|
||||
// Validate field(s) (this will update the form.isValid state)
|
||||
await validateFields(fieldsToValidateOnChange ?? [path]);
|
||||
|
||||
if (isMounted.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we have set a delay to display the error message after the field value has changed,
|
||||
* we first check that this is the last "change iteration" (=== the last keystroke from the user)
|
||||
* and then, we verify how long we've already waited for as form.validateFields() is asynchronous
|
||||
* and might already have taken more than the specified delay)
|
||||
*/
|
||||
if (changeIteration === changeCounter.current) {
|
||||
if (valueChangeDebounceTime > 0) {
|
||||
const timeElapsed = Date.now() - startTime;
|
||||
|
||||
if (timeElapsed < valueChangeDebounceTime) {
|
||||
const timeLeftToWait = valueChangeDebounceTime - timeElapsed;
|
||||
debounceTimeout.current = setTimeout(() => {
|
||||
debounceTimeout.current = null;
|
||||
setIsChangingValue(false);
|
||||
}, timeLeftToWait);
|
||||
return;
|
||||
}
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingValue(false);
|
||||
}
|
||||
}, [
|
||||
path,
|
||||
value,
|
||||
valueChangeDebounceTime,
|
||||
fieldsToValidateOnChange,
|
||||
__updateFormDataAt,
|
||||
validateFields,
|
||||
]);
|
||||
/**
|
||||
* If we have set a delay to display possible validation error message after the field value has changed we
|
||||
* 1. check that this is the last "change iteration" (--> the last keystroke from the user)
|
||||
* 2. verify how long we've already waited for to run the validations (those can be async and make HTTP requests).
|
||||
* 3. (if needed) add a timeout to set the "isChangingValue" state back to "false".
|
||||
*/
|
||||
if (changeIteration === changeCounter.current) {
|
||||
if (valueChangeDebounceTime > 0) {
|
||||
const timeElapsed = Date.now() - startTime;
|
||||
|
||||
if (timeElapsed < valueChangeDebounceTime) {
|
||||
const timeLeftToWait = valueChangeDebounceTime - timeElapsed;
|
||||
|
||||
debounceTimeout.current = setTimeout(() => {
|
||||
debounceTimeout.current = null;
|
||||
done();
|
||||
}, timeLeftToWait);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
},
|
||||
[path, valueChangeDebounceTime, fieldsToValidateOnChange, validateFields]
|
||||
);
|
||||
|
||||
// Cancel any inflight validation (e.g an HTTP Request)
|
||||
const cancelInflightValidation = useCallback(() => {
|
||||
|
@ -201,6 +193,16 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Run all the validations in sequence. If any of the validations is marked as asynchronous
|
||||
* ("isAsync: true") this method will be asynchronous.
|
||||
* The reason why we maintain both a "sync" and "async" option for field.validate() is because
|
||||
* in some cases validating a field must be synchronous (e.g. when adding an item to the EuiCombobox,
|
||||
* we want to first validate the value before adding it. The "onCreateOption" handler expects a boolean
|
||||
* to be returned synchronously).
|
||||
* Keeping both alternative (sync and async) is then a good thing to avoid refactoring dependencies (and
|
||||
* the whole jungle with it!).
|
||||
*/
|
||||
const runValidations = useCallback(
|
||||
(
|
||||
{
|
||||
|
@ -220,15 +222,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
return [];
|
||||
}
|
||||
|
||||
// By default, for fields that have an asynchronous validation
|
||||
// we will clear the errors as soon as the field value changes.
|
||||
clearFieldErrors([
|
||||
validationTypeToValidate ?? VALIDATION_TYPES.FIELD,
|
||||
VALIDATION_TYPES.ASYNC,
|
||||
]);
|
||||
|
||||
cancelInflightValidation();
|
||||
|
||||
// -- helpers
|
||||
const doByPassValidation = ({
|
||||
type: validationType,
|
||||
isBlocking,
|
||||
|
@ -244,8 +238,19 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
return false;
|
||||
};
|
||||
|
||||
const dataProvider: () => Promise<any> =
|
||||
customValidationDataProvider ?? (() => Promise.resolve(undefined));
|
||||
const enhanceValidationError = (
|
||||
validationError: ValidationError,
|
||||
validation: ValidationConfig<FormType, string, I>,
|
||||
validationType: string
|
||||
) => ({
|
||||
...validationError,
|
||||
// We add an "__isBlocking__" property to know if this error is a blocker or no.
|
||||
// Most validation errors are blockers but in some cases a validation is more a warning than an error
|
||||
// (e.g when adding an item to the EuiComboBox item. The item might be invalid and can't be added
|
||||
// but the field (the array of items) is still valid).
|
||||
__isBlocking__: validationError.__isBlocking__ ?? validation.isBlocking,
|
||||
validationType,
|
||||
});
|
||||
|
||||
const runAsync = async () => {
|
||||
const validationErrors: ValidationError[] = [];
|
||||
|
@ -267,8 +272,8 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
form: { getFormData, getFields },
|
||||
formData,
|
||||
path,
|
||||
customData: { provider: dataProvider, value: customValidationData },
|
||||
}) as Promise<ValidationError>;
|
||||
customData: { provider: validationDataProvider, value: validationData },
|
||||
}) as ValidationCancelablePromise;
|
||||
|
||||
const validationResult = await inflightValidation.current;
|
||||
|
||||
|
@ -278,12 +283,9 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
continue;
|
||||
}
|
||||
|
||||
validationErrors.push({
|
||||
...validationResult,
|
||||
// See comment below that explains why we add "__isBlocking__".
|
||||
__isBlocking__: validationResult.__isBlocking__ ?? validation.isBlocking,
|
||||
validationType: validationType || VALIDATION_TYPES.FIELD,
|
||||
});
|
||||
validationErrors.push(
|
||||
enhanceValidationError(validationResult, validation, validationType)
|
||||
);
|
||||
|
||||
if (exitOnFail) {
|
||||
break;
|
||||
|
@ -295,7 +297,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
|
||||
const runSync = () => {
|
||||
const validationErrors: ValidationError[] = [];
|
||||
// Sequentially execute all the validations for the field
|
||||
|
||||
for (const validation of validations) {
|
||||
const {
|
||||
validator,
|
||||
|
@ -313,7 +315,7 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
form: { getFormData, getFields },
|
||||
formData,
|
||||
path,
|
||||
customData: { provider: dataProvider, value: customValidationData },
|
||||
customData: { provider: validationDataProvider, value: validationData },
|
||||
});
|
||||
|
||||
if (!validationResult) {
|
||||
|
@ -321,24 +323,21 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
}
|
||||
|
||||
if (!!validationResult.then) {
|
||||
// The validator returned a Promise: abort and run the validations asynchronously
|
||||
// We keep a reference to the onflith promise so we can cancel it.
|
||||
// The validator returned a Promise: abort and run the validations asynchronously.
|
||||
// This is a fallback mechansim, it is recommended to explicitly mark a validation
|
||||
// as asynchronous with the "isAsync" flag to avoid runnning twice the same validation
|
||||
// (and possible HTTP requests).
|
||||
// We keep a reference to the inflight promise so we can cancel it.
|
||||
|
||||
inflightValidation.current = validationResult as Promise<ValidationError>;
|
||||
inflightValidation.current = validationResult as ValidationCancelablePromise;
|
||||
cancelInflightValidation();
|
||||
|
||||
return runAsync();
|
||||
}
|
||||
|
||||
validationErrors.push({
|
||||
...(validationResult as ValidationError),
|
||||
// We add an "__isBlocking__" property to know if this error is a blocker or no.
|
||||
// Most validation errors are blockers but in some cases a validation is more a warning than an error
|
||||
// like with the ComboBox items when they are added.
|
||||
__isBlocking__:
|
||||
(validationResult as ValidationError).__isBlocking__ ?? validation.isBlocking,
|
||||
validationType: validationType || VALIDATION_TYPES.FIELD,
|
||||
});
|
||||
validationErrors.push(
|
||||
enhanceValidationError(validationResult as ValidationError, validation, validationType)
|
||||
);
|
||||
|
||||
if (exitOnFail) {
|
||||
break;
|
||||
|
@ -347,12 +346,19 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
|
||||
return validationErrors;
|
||||
};
|
||||
// -- end helpers
|
||||
|
||||
clearFieldErrors([
|
||||
validationTypeToValidate ?? VALIDATION_TYPES.FIELD,
|
||||
VALIDATION_TYPES.ASYNC, // Immediately clear errors for "async" type validations.
|
||||
]);
|
||||
|
||||
cancelInflightValidation();
|
||||
|
||||
if (hasAsyncValidation) {
|
||||
return runAsync();
|
||||
}
|
||||
|
||||
// We first try to run the validations synchronously
|
||||
return runSync();
|
||||
},
|
||||
[
|
||||
|
@ -362,8 +368,8 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
getFormData,
|
||||
getFields,
|
||||
path,
|
||||
customValidationData,
|
||||
customValidationDataProvider,
|
||||
validationData,
|
||||
validationDataProvider,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -388,13 +394,13 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
);
|
||||
|
||||
const validate: FieldHook<T, I>['validate'] = useCallback(
|
||||
(validationData = {}) => {
|
||||
(validationConfig = {}) => {
|
||||
const {
|
||||
formData = __getFormData$().value,
|
||||
value: valueToValidate = value,
|
||||
validationType = VALIDATION_TYPES.FIELD,
|
||||
onlyBlocking = false,
|
||||
} = validationData;
|
||||
} = validationConfig;
|
||||
|
||||
setValidating(true);
|
||||
|
||||
|
@ -443,13 +449,13 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
const setValue: FieldHook<T, I>['setValue'] = useCallback(
|
||||
(newValue) => {
|
||||
setStateValue((prev) => {
|
||||
let formattedValue: I;
|
||||
if (typeof newValue === 'function') {
|
||||
formattedValue = formatInputValue<I>((newValue as Function)(prev));
|
||||
} else {
|
||||
formattedValue = formatInputValue<I>(newValue);
|
||||
let _newValue = newValue;
|
||||
|
||||
if (typeof _newValue === 'function') {
|
||||
_newValue = (_newValue as Function)(prev);
|
||||
}
|
||||
return formattedValue;
|
||||
|
||||
return formatInputValue(_newValue);
|
||||
});
|
||||
},
|
||||
[formatInputValue]
|
||||
|
@ -476,19 +482,8 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
[setValue]
|
||||
);
|
||||
|
||||
/**
|
||||
* As we can have multiple validation types (FIELD, ASYNC, ARRAY_ITEM), this
|
||||
* method allows us to retrieve error messages for certain types of validation.
|
||||
*
|
||||
* For example, if we want to validation error messages to be displayed when the user clicks the "save" button
|
||||
* _but_ in case of an asynchronous validation (for ex. an HTTP request that would validate an index name) we
|
||||
* want to immediately display the error message, we would have 2 types of validation: FIELD & ASYNC
|
||||
*
|
||||
* @param validationType The validation type to return error messages from
|
||||
*/
|
||||
const getErrorsMessages: FieldHook<T, I>['getErrorsMessages'] = useCallback(
|
||||
(args = {}) => {
|
||||
const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args;
|
||||
({ errorCode, validationType = VALIDATION_TYPES.FIELD } = {}) => {
|
||||
const errorMessages = errors.reduce((messages, error) => {
|
||||
const isSameErrorCode = errorCode && error.code === errorCode;
|
||||
const isSamevalidationType =
|
||||
|
@ -497,8 +492,11 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
!{}.hasOwnProperty.call(error, 'validationType'));
|
||||
|
||||
if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) {
|
||||
return messages ? `${messages}, ${error.message}` : (error.message as string);
|
||||
return messages
|
||||
? `${messages}, ${error.message}` // concatenate error message
|
||||
: error.message;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}, '');
|
||||
|
||||
|
@ -589,16 +587,22 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
// -- EFFECTS
|
||||
// ----------------------------------
|
||||
useEffect(() => {
|
||||
__addField(field as FieldHook<any>);
|
||||
// Add the fieldHook object to the form "fieldsRefs" map
|
||||
__addField(field);
|
||||
}, [field, __addField]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// We only remove the field from the form "fieldsRefs" map when its path
|
||||
// changes (which in practice never occurs) or whenever the <UseField /> unmounts
|
||||
__removeField(path);
|
||||
};
|
||||
}, [path, __removeField]);
|
||||
|
||||
// Notify listener whenever the value changes
|
||||
// Value change: notify prop listener (<UseField onChange={() => {...}})
|
||||
// We have a separate useEffect for this as the "onChange" handler pass through prop
|
||||
// might not be wrapped inside a "useCallback" and that would trigger a possible infinite
|
||||
// amount of effect executions.
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
|
@ -610,20 +614,26 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
}
|
||||
}, [value, valueChangeListener]);
|
||||
|
||||
// Value change: update state and run validations
|
||||
useEffect(() => {
|
||||
// If the field value has been reset, we don't want to call the "onValueChange()"
|
||||
// as it will set the "isPristine" state to true or validate the field, which we don't want
|
||||
// to occur right after resetting the field state.
|
||||
if (hasBeenReset.current) {
|
||||
hasBeenReset.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onValueChange();
|
||||
if (hasBeenReset.current) {
|
||||
// If the field value has just been reset (triggering this useEffect)
|
||||
// we don't want to set the "isPristine" state to true and validate the field
|
||||
hasBeenReset.current = false;
|
||||
} else {
|
||||
setPristine(false);
|
||||
setIsChangingValue(true);
|
||||
|
||||
runValidationsOnValueChange(() => {
|
||||
if (isMounted.current) {
|
||||
setIsChangingValue(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (debounceTimeout.current) {
|
||||
|
@ -631,17 +641,19 @@ export const useField = <T, FormType = FormData, I = T>(
|
|||
debounceTimeout.current = null;
|
||||
}
|
||||
};
|
||||
}, [onValueChange]);
|
||||
}, [value, runValidationsOnValueChange]);
|
||||
|
||||
// Value change: set "isModified" state
|
||||
useEffect(() => {
|
||||
setIsModified(() => {
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value) !== JSON.stringify(initialValue);
|
||||
return JSON.stringify(value) !== JSON.stringify(initialValueDeserialized);
|
||||
}
|
||||
return value !== initialValue;
|
||||
return value !== initialValueDeserialized;
|
||||
});
|
||||
}, [value, initialValue]);
|
||||
}, [value, initialValueDeserialized]);
|
||||
|
||||
// Errors change: notify prop listener (<UseField onError={() => {...}} />)
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
|
|
|
@ -10,7 +10,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|||
import { get } from 'lodash';
|
||||
import { set } from '@elastic/safer-lodash-set';
|
||||
|
||||
import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types';
|
||||
import { FormHook, FieldHook, FormData, FieldsMap, FormConfig } from '../types';
|
||||
import { mapFormFields, unflattenObject, Subject, Subscription } from '../lib';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
|
@ -35,22 +35,25 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
defaultValue,
|
||||
} = formConfig ?? {};
|
||||
|
||||
// Strip out any "undefined" value and run the deserializer
|
||||
const initDefaultValue = useCallback(
|
||||
(_defaultValue?: Partial<T>): { [key: string]: any } => {
|
||||
(_defaultValue?: Partial<T>): I | undefined => {
|
||||
if (_defaultValue === undefined || Object.keys(_defaultValue).length === 0) {
|
||||
return {};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filtered = Object.entries(_defaultValue as object)
|
||||
.filter(({ 1: value }) => value !== undefined)
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T);
|
||||
|
||||
return deserializer ? deserializer(filtered) : filtered;
|
||||
return deserializer ? deserializer(filtered) : (filtered as unknown as I);
|
||||
},
|
||||
[deserializer]
|
||||
);
|
||||
|
||||
const defaultValueMemoized = useMemo<{ [key: string]: any }>(() => {
|
||||
// We create this stable reference to be able to initialize our "defaultValueDeserialized" ref below
|
||||
// as we can't initialize useRef by calling a function (e.g. useRef(initDefaultValue()))
|
||||
const defaultValueMemoized = useMemo<I | undefined>(() => {
|
||||
return initDefaultValue(defaultValue);
|
||||
}, [defaultValue, initDefaultValue]);
|
||||
|
||||
|
@ -68,15 +71,33 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
|
||||
const [errorMessages, setErrorMessages] = useState<{ [fieldName: string]: string }>({});
|
||||
|
||||
/**
|
||||
* Map of all the fields currently in the form
|
||||
*/
|
||||
const fieldsRefs = useRef<FieldsMap>({});
|
||||
/**
|
||||
* Keep a track of the fields that have been removed from the form.
|
||||
* This will allow us to know if the form has been modified
|
||||
* (this ref is then accessed in the "useFormIsModified()" hook)
|
||||
*/
|
||||
const fieldsRemovedRefs = useRef<FieldsMap>({});
|
||||
/**
|
||||
* A list of all subscribers to form data and validity changes that
|
||||
* called "form.subscribe()"
|
||||
*/
|
||||
const formUpdateSubscribers = useRef<Subscription[]>([]);
|
||||
const isMounted = useRef<boolean>(false);
|
||||
/**
|
||||
* Keep a reference to the form defaultValue once it has been deserialized.
|
||||
* This allows us to reset the form and put back the initial value of each fields
|
||||
*/
|
||||
const defaultValueDeserialized = useRef(defaultValueMemoized);
|
||||
|
||||
/**
|
||||
* We have both a state and a ref for the error messages so the consumer can, in the same callback,
|
||||
* validate the form **and** have the errors returned immediately.
|
||||
* Note: As an alternative we could return the errors when calling the "validate()" method but that creates
|
||||
* a breaking change in the API which would require to update many forms.
|
||||
*
|
||||
* ```
|
||||
* const myHandler = useCallback(async () => {
|
||||
|
@ -87,16 +108,22 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
*/
|
||||
const errorMessagesRef = useRef<{ [fieldName: string]: string }>({});
|
||||
|
||||
// formData$ is an observable we can subscribe to in order to receive live
|
||||
// update of the raw form data. As an observable it does not trigger any React
|
||||
// render().
|
||||
// The "useFormData()" hook is the one in charge of reading this observable
|
||||
// and updating its own state that will trigger the necessary re-renders in the UI.
|
||||
/**
|
||||
* formData$ is an observable that gets updated every time a field value changes.
|
||||
* It is the "useFormData()" hook that subscribes to this observable and updates
|
||||
* its internal "formData" state that in turn triggers the necessary re-renders in the consumer component.
|
||||
*/
|
||||
const formData$ = useRef<Subject<FormData> | null>(null);
|
||||
|
||||
// ----------------------------------
|
||||
// -- HELPERS
|
||||
// ----------------------------------
|
||||
/**
|
||||
* We can't initialize a React ref by calling a function (in this case
|
||||
* useRef(new Subject())) the function is called on every render and would
|
||||
* create a new "Subject" instance.
|
||||
* We use this handler to access the ref and initialize it on first access.
|
||||
*/
|
||||
const getFormData$ = useCallback((): Subject<FormData> => {
|
||||
if (formData$.current === null) {
|
||||
formData$.current = new Subject<FormData>({});
|
||||
|
@ -124,7 +151,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
}
|
||||
|
||||
if (errorMessage === null) {
|
||||
// We strip out previous error message
|
||||
// The field at this path is now valid, we strip out any previous error message
|
||||
const { [path]: discard, ...next } = prev;
|
||||
errorMessagesRef.current = next;
|
||||
return next;
|
||||
|
@ -175,7 +202,10 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
|
||||
const updateDefaultValueAt: FormHook<T, I>['__updateDefaultValueAt'] = useCallback(
|
||||
(path, value) => {
|
||||
set(defaultValueDeserialized.current, path, value);
|
||||
if (defaultValueDeserialized.current === undefined) {
|
||||
defaultValueDeserialized.current = {} as I;
|
||||
}
|
||||
set(defaultValueDeserialized.current!, path, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
@ -200,81 +230,25 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
});
|
||||
}, [fieldsToArray]);
|
||||
|
||||
const validateFields: FormHook<T, I>['validateFields'] = useCallback(
|
||||
async (fieldNames, onlyBlocking = false) => {
|
||||
const fieldsToValidate = fieldNames
|
||||
.map((name) => fieldsRefs.current[name])
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
const formData = getFormData$().value;
|
||||
const validationResult = await Promise.all(
|
||||
fieldsToValidate.map((field) => field.validate({ formData, onlyBlocking }))
|
||||
);
|
||||
|
||||
if (isMounted.current === false) {
|
||||
return { areFieldsValid: true, isFormValid: true };
|
||||
}
|
||||
|
||||
const areFieldsValid = validationResult.every((res) => res.isValid);
|
||||
|
||||
const validationResultByPath = fieldsToValidate.reduce((acc, field, i) => {
|
||||
acc[field.path] = validationResult[i].isValid;
|
||||
return acc;
|
||||
}, {} as { [key: string]: boolean });
|
||||
|
||||
// At this stage we have an updated field validation state inside the "validationResultByPath" object.
|
||||
// The fields we have in our "fieldsRefs.current" have not been updated yet with the new validation state
|
||||
// (isValid, isValidated...) as this will happen _after_, when the "useEffect" triggers and calls "addField()".
|
||||
// This means that we have **stale state value** in our fieldsRefs.
|
||||
// To know the current form validity, we will then merge the "validationResult" _with_ the fieldsRefs object state,
|
||||
// the "validationResult" taking presedence over the fieldsRefs values.
|
||||
const formFieldsValidity = fieldsToArray().map((field) => {
|
||||
const hasUpdatedValidity = validationResultByPath[field.path] !== undefined;
|
||||
const _isValid = validationResultByPath[field.path] ?? field.isValid;
|
||||
const _isValidated = hasUpdatedValidity ? true : field.isValidated;
|
||||
const _isValidating = hasUpdatedValidity ? false : field.isValidating;
|
||||
return {
|
||||
isValid: _isValid,
|
||||
isValidated: _isValidated,
|
||||
isValidating: _isValidating,
|
||||
};
|
||||
});
|
||||
|
||||
const areAllFieldsValidated = formFieldsValidity.every((field) => field.isValidated);
|
||||
const areSomeFieldValidating = formFieldsValidity.some((field) => field.isValidating);
|
||||
|
||||
// If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined"
|
||||
const isFormValid =
|
||||
areAllFieldsValidated && areSomeFieldValidating === false
|
||||
? formFieldsValidity.every((field) => field.isValid)
|
||||
: undefined;
|
||||
|
||||
setIsValid(isFormValid);
|
||||
|
||||
return { areFieldsValid, isFormValid };
|
||||
},
|
||||
[getFormData$, fieldsToArray]
|
||||
);
|
||||
|
||||
// ----------------------------------
|
||||
// -- Internal API
|
||||
// ----------------------------------
|
||||
const addField: FormHook<T, I>['__addField'] = useCallback(
|
||||
(field) => {
|
||||
const fieldExists = fieldsRefs.current[field.path] !== undefined;
|
||||
const fieldPreviouslyAdded = fieldsRefs.current[field.path] !== undefined;
|
||||
fieldsRefs.current[field.path] = field;
|
||||
delete fieldsRemovedRefs.current[field.path];
|
||||
|
||||
updateFormDataAt(field.path, field.value);
|
||||
updateFieldErrorMessage(field.path, field.getErrorsMessages());
|
||||
|
||||
if (!fieldExists && !field.isValidated) {
|
||||
if (!fieldPreviouslyAdded && !field.isValidated) {
|
||||
setIsValid(undefined);
|
||||
|
||||
// When we submit the form (and set "isSubmitted" to "true"), we validate **all fields**.
|
||||
// If a field is added and it is not validated it means that we have swapped fields and added new ones:
|
||||
// --> we have basically have a new form in front of us.
|
||||
// For that reason we make sure that the "isSubmitted" state is false.
|
||||
// When we submit() the form we set the "isSubmitted" state to "true" and all fields are marked as "isValidated: true".
|
||||
// If a **new** field is added and and its "isValidated" is "false" it means that we have swapped fields and added new ones:
|
||||
// --> we have a new form in front of us with different set of fields. We need to reset the "isSubmitted" state.
|
||||
// (e.g. In the mappings editor when the user switches the field "type" it brings a whole new set of settings)
|
||||
setIsSubmitted(false);
|
||||
}
|
||||
},
|
||||
|
@ -284,18 +258,16 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
const removeField: FormHook<T, I>['__removeField'] = useCallback(
|
||||
(_fieldNames) => {
|
||||
const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames];
|
||||
const currentFormData = { ...getFormData$().value };
|
||||
const updatedFormData = { ...getFormData$().value };
|
||||
|
||||
fieldNames.forEach((name) => {
|
||||
// Keep a track of the fields that have been removed from the form
|
||||
// This will allow us to know if the form has been modified
|
||||
fieldsRemovedRefs.current[name] = fieldsRefs.current[name];
|
||||
updateFieldErrorMessage(name, null);
|
||||
delete fieldsRefs.current[name];
|
||||
delete currentFormData[name];
|
||||
delete updatedFormData[name];
|
||||
});
|
||||
|
||||
updateFormData$(currentFormData);
|
||||
updateFormData$(updatedFormData);
|
||||
|
||||
/**
|
||||
* After removing a field, the form validity might have changed
|
||||
|
@ -306,7 +278,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
const isFormValid = fieldsToArray().every(isFieldValid);
|
||||
return isFormValid;
|
||||
}
|
||||
// If the form validity is "true" or "undefined", it does not change after removing a field
|
||||
// If the form validity is "true" or "undefined", it remains the same after removing a field
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
|
@ -320,7 +292,7 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
|
||||
const readFieldConfigFromSchema: FormHook<T, I>['__readFieldConfigFromSchema'] = useCallback(
|
||||
(fieldName) => {
|
||||
const config = (get(schema ?? {}, fieldName) as FieldConfig) || {};
|
||||
const config = get(schema ?? {}, fieldName);
|
||||
|
||||
return config;
|
||||
},
|
||||
|
@ -335,6 +307,61 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
// ----------------------------------
|
||||
// -- Public API
|
||||
// ----------------------------------
|
||||
const validateFields: FormHook<T, I>['validateFields'] = useCallback(
|
||||
async (fieldNames, onlyBlocking = false) => {
|
||||
const fieldsToValidate = fieldNames
|
||||
.map((name) => fieldsRefs.current[name])
|
||||
.filter((field) => field !== undefined);
|
||||
|
||||
const formData = getFormData$().value;
|
||||
const validationResult = await Promise.all(
|
||||
fieldsToValidate.map((field) => field.validate({ formData, onlyBlocking }))
|
||||
);
|
||||
|
||||
if (isMounted.current === false) {
|
||||
// If the form has unmounted while validating, the result is not pertinent
|
||||
// anymore. Let's satisfy TS and exit.
|
||||
return { areFieldsValid: true, isFormValid: true };
|
||||
}
|
||||
|
||||
const areFieldsValid = validationResult.every((res) => res.isValid);
|
||||
|
||||
const validationResultByPath = fieldsToValidate.reduce((acc, field, i) => {
|
||||
acc[field.path] = validationResult[i].isValid;
|
||||
return acc;
|
||||
}, {} as { [fieldPath: string]: boolean });
|
||||
|
||||
// At this stage we have an updated field validation state inside the "validationResultByPath" object.
|
||||
// The fields object in "fieldsRefs.current" have not been updated yet with their new validation state
|
||||
// (isValid, isValidated...) as this occurs later, when the "useEffect" kicks in and calls "addField()" on the form.
|
||||
// This means that we have **stale state value** in our fieldsRefs map.
|
||||
// To know the current form validity, we will then merge the "validationResult" with the fieldsRefs object state.
|
||||
const formFieldsValidity = fieldsToArray().map((field) => {
|
||||
const hasUpdatedValidity = validationResultByPath[field.path] !== undefined;
|
||||
|
||||
return {
|
||||
isValid: validationResultByPath[field.path] ?? field.isValid,
|
||||
isValidated: hasUpdatedValidity ? true : field.isValidated,
|
||||
isValidating: hasUpdatedValidity ? false : field.isValidating,
|
||||
};
|
||||
});
|
||||
|
||||
const areAllFieldsValidated = formFieldsValidity.every((field) => field.isValidated);
|
||||
const areSomeFieldValidating = formFieldsValidity.some((field) => field.isValidating);
|
||||
|
||||
// If *not* all the fields have been validated, the validity of the form is unknown, thus still "undefined"
|
||||
const isFormValid =
|
||||
areAllFieldsValidated && areSomeFieldValidating === false
|
||||
? formFieldsValidity.every((field) => field.isValid)
|
||||
: undefined;
|
||||
|
||||
setIsValid(isFormValid);
|
||||
|
||||
return { areFieldsValid, isFormValid };
|
||||
},
|
||||
[getFormData$, fieldsToArray]
|
||||
);
|
||||
|
||||
const getFormData: FormHook<T, I>['getFormData'] = useCallback(() => {
|
||||
const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, {
|
||||
stripEmptyFields: formOptions.stripEmptyFields,
|
||||
|
@ -362,6 +389,8 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
}
|
||||
|
||||
const fieldsArray = fieldsToArray();
|
||||
// We only need to validate the fields that haven't been validated yet. Those
|
||||
// are pristine fields (dirty fields are always validated when their value changed)
|
||||
const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated);
|
||||
|
||||
let isFormValid: boolean | undefined;
|
||||
|
@ -370,7 +399,11 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
isFormValid = fieldsArray.every(isFieldValid);
|
||||
} else {
|
||||
const fieldPathsToValidate = fieldsToValidate.map((field) => field.path);
|
||||
({ isFormValid } = await validateFields(fieldPathsToValidate, true));
|
||||
const validateOnlyBlockingValidation = true;
|
||||
({ isFormValid } = await validateFields(
|
||||
fieldPathsToValidate,
|
||||
validateOnlyBlockingValidation
|
||||
));
|
||||
}
|
||||
|
||||
setIsValid(isFormValid);
|
||||
|
@ -378,23 +411,21 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
}, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]);
|
||||
|
||||
const setFieldValue: FormHook<T, I>['setFieldValue'] = useCallback((fieldName, value) => {
|
||||
if (fieldsRefs.current[fieldName] === undefined) {
|
||||
return;
|
||||
if (fieldsRefs.current[fieldName]) {
|
||||
fieldsRefs.current[fieldName].setValue(value);
|
||||
}
|
||||
fieldsRefs.current[fieldName].setValue(value);
|
||||
}, []);
|
||||
|
||||
const setFieldErrors: FormHook<T, I>['setFieldErrors'] = useCallback((fieldName, errors) => {
|
||||
if (fieldsRefs.current[fieldName] === undefined) {
|
||||
return;
|
||||
if (fieldsRefs.current[fieldName]) {
|
||||
fieldsRefs.current[fieldName].setErrors(errors);
|
||||
}
|
||||
fieldsRefs.current[fieldName].setErrors(errors);
|
||||
}, []);
|
||||
|
||||
const getFields: FormHook<T, I>['getFields'] = useCallback(() => fieldsRefs.current, []);
|
||||
|
||||
const getFieldDefaultValue: FormHook<T, I>['getFieldDefaultValue'] = useCallback(
|
||||
(fieldName) => get(defaultValueDeserialized.current, fieldName),
|
||||
(fieldName) => get(defaultValueDeserialized.current ?? {}, fieldName),
|
||||
[]
|
||||
);
|
||||
|
||||
|
@ -457,8 +488,9 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
}
|
||||
|
||||
Object.entries(fieldsRefs.current).forEach(([path, field]) => {
|
||||
// By resetting the form, some field might be unmounted. In order
|
||||
// to avoid a race condition, we check that the field still exists.
|
||||
// By resetting the form and changing field values, some fields might be unmounted
|
||||
// (e.g. a toggle might be set back to "false" and some fields removed from the UI as a consequence).
|
||||
// We make sure that the field still exists before resetting it.
|
||||
const isFieldMounted = fieldsRefs.current[path] !== undefined;
|
||||
if (isFieldMounted) {
|
||||
const fieldDefaultValue = getFieldDefaultValue(path);
|
||||
|
@ -532,6 +564,10 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
|
|||
validate,
|
||||
]);
|
||||
|
||||
// ----------------------------------
|
||||
// -- EFFECTS
|
||||
// ----------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
|
|
|
@ -16,8 +16,8 @@ interface Options<I> {
|
|||
watch?: string | string[];
|
||||
form?: FormHook<any>;
|
||||
/**
|
||||
* Use this handler if you want to listen to field value change
|
||||
* before the validations are ran.
|
||||
* Use this handler if you want to listen to field values changes immediately
|
||||
* (**before** the validations are ran) instead of relying on a useEffect()
|
||||
*/
|
||||
onChange?: (formData: I) => void;
|
||||
}
|
||||
|
@ -51,20 +51,35 @@ export const useFormData = <I extends object = FormData, T extends object = I>(
|
|||
|
||||
const previousRawData = useRef<FormData>(initialValue);
|
||||
const isMounted = useRef(false);
|
||||
/**
|
||||
* The first time the subscribe listener is called with no form data (empty object)
|
||||
* this means that no field has mounted --> we don't want to update the state just yet.
|
||||
*/
|
||||
const isFirstSubscribeListenerCall = useRef(true);
|
||||
const [formData, setFormData] = useState<I>(() => unflattenObject<I>(previousRawData.current));
|
||||
|
||||
/**
|
||||
* We do want to offer to the consumer a handler to serialize the form data that changes each time
|
||||
* the formData **state** changes. This is why we added the "formData" dep to the array and added the eslint override.
|
||||
*/
|
||||
const serializer = useCallback(() => {
|
||||
const formDataSerializer = useCallback(() => {
|
||||
return getFormData();
|
||||
/**
|
||||
* The "form.getFormData()" handler (which serializes the form data) is a static ref that does not change
|
||||
* when the underlying form data changes. As we do want to return to the consumer a handler to serialize the form data
|
||||
* that **does** changes along with the form data we've added the "formData" state to the useCallback dependencies.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getFormData, formData]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = getFormData$().subscribe((raw) => {
|
||||
if (!isMounted.current && Object.keys(raw).length === 0) {
|
||||
if (isFirstSubscribeListenerCall.current && Object.keys(raw).length === 0) {
|
||||
// No field has mounted and been added to the form yet, skip this invocation.
|
||||
isFirstSubscribeListenerCall.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -78,14 +93,18 @@ export const useFormData = <I extends object = FormData, T extends object = I>(
|
|||
onChange(nextState);
|
||||
}
|
||||
|
||||
setFormData(nextState);
|
||||
if (isMounted.current) {
|
||||
setFormData(nextState);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const nextState = unflattenObject<I>(raw);
|
||||
if (onChange) {
|
||||
onChange(nextState);
|
||||
}
|
||||
setFormData(nextState);
|
||||
if (isMounted.current) {
|
||||
setFormData(nextState);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -95,17 +114,10 @@ export const useFormData = <I extends object = FormData, T extends object = I>(
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stringifiedWatch, getFormData$, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted.current && Object.keys(formData).length === 0) {
|
||||
// No field has mounted yet
|
||||
return [formData, serializer, false];
|
||||
return [formData, formDataSerializer, false];
|
||||
}
|
||||
|
||||
return [formData, serializer, true];
|
||||
return [formData, formDataSerializer, true];
|
||||
};
|
||||
|
|
|
@ -14,7 +14,11 @@ import { useFormData } from './use_form_data';
|
|||
|
||||
interface Options {
|
||||
form?: FormHook<any>;
|
||||
/** List of field paths to discard when checking if a field has been modified */
|
||||
/**
|
||||
* List of field paths to discard when checking if a field has been modified.
|
||||
* Useful when we add internal fields (e.g. toggles) to the form that should not
|
||||
* have an impact on the "isModified" state.
|
||||
*/
|
||||
discard?: string[];
|
||||
}
|
||||
|
||||
|
@ -23,16 +27,16 @@ interface Options {
|
|||
* If a field is modified and then the value is changed back to the initial value
|
||||
* the form **won't be marked as modified**.
|
||||
* This is useful to detect if a form has changed and we need to display a confirm modal
|
||||
* to the user before he navigates away and loses his changes.
|
||||
* to the user before they navigate away and lose their changes.
|
||||
*
|
||||
* @param options - Optional options object
|
||||
* @returns flag to indicate if the form has been modified
|
||||
*/
|
||||
export const useFormIsModified = ({
|
||||
form: formFromOptions,
|
||||
discard = [],
|
||||
discard: fieldPathsToDiscard = [],
|
||||
}: Options = {}): boolean => {
|
||||
// As hook calls can not be conditional we first try to access the form through context
|
||||
// Hook calls can not be conditional we first try to access the form through context
|
||||
let form = useFormContext({ throwIfNotFound: false });
|
||||
|
||||
if (formFromOptions) {
|
||||
|
@ -47,39 +51,41 @@ export const useFormIsModified = ({
|
|||
|
||||
const { getFields, __getFieldsRemoved, __getFormDefaultValue } = form;
|
||||
|
||||
const discardToString = JSON.stringify(discard);
|
||||
const discardArrayToString = JSON.stringify(fieldPathsToDiscard);
|
||||
|
||||
// Create a map of the fields to discard to optimize look up
|
||||
const fieldsToDiscard = useMemo(() => {
|
||||
if (discard.length === 0) {
|
||||
if (fieldPathsToDiscard.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return discard.reduce((acc, path) => ({ ...acc, [path]: {} }), {} as { [key: string]: {} });
|
||||
return fieldPathsToDiscard.reduce(
|
||||
(acc, path) => ({ ...acc, [path]: true }),
|
||||
{} as { [key: string]: {} }
|
||||
);
|
||||
|
||||
// discardToString === discard, we don't want to add it to the deps so we
|
||||
// the coansumer does not need to memoize the array he provides.
|
||||
}, [discardToString]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// discardArrayToString === discard, we don't want to add it to the dependencies so
|
||||
// the consumer does not need to memoize the "discard" array they provide.
|
||||
}, [discardArrayToString]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// We listen to all the form data change to trigger a re-render
|
||||
// and update our derived "isModified" state
|
||||
useFormData({ form });
|
||||
|
||||
let predicate: (arg: [string, FieldHook]) => boolean = () => true;
|
||||
|
||||
if (fieldsToDiscard) {
|
||||
predicate = ([path]) => fieldsToDiscard[path] === undefined;
|
||||
}
|
||||
const isFieldIncluded = fieldsToDiscard
|
||||
? ([path]: [string, FieldHook]) => fieldsToDiscard[path] !== true
|
||||
: () => true;
|
||||
|
||||
// 1. Check if any field has been modified
|
||||
let isModified = Object.entries(getFields())
|
||||
.filter(predicate)
|
||||
.filter(isFieldIncluded)
|
||||
.some(([_, field]) => field.isModified);
|
||||
|
||||
if (isModified) {
|
||||
return isModified;
|
||||
}
|
||||
|
||||
// Check if any field has been removed.
|
||||
// 2. Check if any field has been removed.
|
||||
// If somme field has been removed **and** they were originaly present on the
|
||||
// form "defaultValue" then the form has been modified.
|
||||
const formDefaultValue = __getFormDefaultValue();
|
||||
|
@ -87,7 +93,7 @@ export const useFormIsModified = ({
|
|||
|
||||
const fieldsRemovedFromDOM: string[] = fieldsToDiscard
|
||||
? Object.keys(__getFieldsRemoved())
|
||||
.filter((path) => fieldsToDiscard[path] === undefined)
|
||||
.filter((path) => fieldsToDiscard[path] !== true)
|
||||
.filter(fieldOnFormDefaultValue)
|
||||
: Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue);
|
||||
|
||||
|
|
|
@ -8,6 +8,65 @@
|
|||
import { useCallback, useRef, useMemo } from 'react';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Utility to create an observable with a handler to update its value.
|
||||
* Useful for validations when dynamic data needs to be passed through the
|
||||
* "validationDataProvider" prop **and** this dynamic data arrives _after_ the
|
||||
* field value has changed (e.g. when the field value changes it triggers an HTTP requests and
|
||||
* the field validators needs the response to be able to validate the field).
|
||||
*
|
||||
* @param initialState any The initial value of the observable
|
||||
* @returns Array<Observable, (newValue) => void>
|
||||
*
|
||||
* @example
|
||||
*
|
||||
const MyForm = () => {
|
||||
const { form } = useForm();
|
||||
const [indices, setIndices] = useState([]);
|
||||
const [indices$, nextIndices] = useBehaviorSubject(null); // Use the provided util hook to create an observable
|
||||
|
||||
const onIndexNameChange = useCallback(({ indexName }) => {
|
||||
// Whenever the indexName changes reset the subject to not send stale data to the validator
|
||||
nextIndices(null);
|
||||
}, []);
|
||||
|
||||
const [{ indexName }] = useFormData({
|
||||
form,
|
||||
watch: 'indexName',
|
||||
onChange: onIndexNameChange, // React to changes before any validation is executed
|
||||
});
|
||||
|
||||
const indicesProvider = useCallback(() => {
|
||||
// Wait until we have fetched the indices.
|
||||
// The result will then be sent to the field validator(s) (when calling await provider(););
|
||||
return await firstValueFrom(indices$.pipe(first((data) => data !== null)));
|
||||
}, [indices$, nextIndices]);
|
||||
|
||||
const fetchIndices = useCallback(async () => {
|
||||
const result = await httpClient.get(`/api/search/${indexName}`);
|
||||
// update the component state
|
||||
setIndices(result);
|
||||
|
||||
// Send the indices to the BehaviorSubject to resolve the "indicesProvider()" promise
|
||||
nextIndices(result);
|
||||
}, [indexName]);
|
||||
|
||||
// Whenever the indexName changes we fetch the indices
|
||||
useEffect(() => {
|
||||
fetchIndices();
|
||||
}, [fetchIndices]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form form={form}>
|
||||
<UseField path="indexName" validationDataProvider={indicesProvider} />
|
||||
</Form>
|
||||
|
||||
...
|
||||
<>
|
||||
);
|
||||
}
|
||||
*/
|
||||
export const useBehaviorSubject = <T = any>(initialState: T) => {
|
||||
const subjectRef = useRef<BehaviorSubject<T>>();
|
||||
|
||||
|
|
|
@ -6,12 +6,46 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
// We don't export the "useField" hook as it is for internal use.
|
||||
// The consumer of the library must use the <UseField /> component to create a field
|
||||
// We don't export the "useField()" hook as it is created internally.
|
||||
// Consumers must use the <UseField />, <UseArray /> or <UseMultiFields />
|
||||
// components to create fields.
|
||||
export { useForm, useFormData, useFormIsModified, useBehaviorSubject } from './hooks';
|
||||
|
||||
export { getFieldValidityAndErrorMessage } from './helpers';
|
||||
|
||||
export * from './form_context';
|
||||
export * from './components';
|
||||
export * from './constants';
|
||||
export * from './types';
|
||||
export { FormProvider, useFormContext } from './form_context';
|
||||
|
||||
export {
|
||||
Form,
|
||||
getUseField,
|
||||
UseArray,
|
||||
UseField,
|
||||
UseMultiFields,
|
||||
FormDataProvider,
|
||||
} from './components';
|
||||
|
||||
export type { ArrayItem, FormArrayField, UseFieldProps } from './components';
|
||||
|
||||
export { FIELD_TYPES, VALIDATION_TYPES } from './constants';
|
||||
|
||||
export type {
|
||||
FieldConfig,
|
||||
FieldHook,
|
||||
FieldsMap,
|
||||
FieldValidateResponse,
|
||||
FormConfig,
|
||||
FormData,
|
||||
FormHook,
|
||||
FormOptions,
|
||||
FormSchema,
|
||||
FormSubmitHandler,
|
||||
OnFormUpdateArg,
|
||||
OnUpdateHandler,
|
||||
SerializerFunc,
|
||||
ValidationCancelablePromise,
|
||||
ValidationConfig,
|
||||
ValidationError,
|
||||
ValidationFunc,
|
||||
ValidationFuncArg,
|
||||
ValidationResponsePromise,
|
||||
} from './types';
|
||||
|
|
|
@ -37,11 +37,8 @@ export interface FormHook<T extends FormData = FormData, I extends FormData = T>
|
|||
/** Access the fields on the form. */
|
||||
getFields: () => FieldsMap;
|
||||
/** Access the defaultValue for a specific field */
|
||||
getFieldDefaultValue: (path: string) => unknown;
|
||||
/**
|
||||
* Return the form data. It accepts an optional options object with an `unflatten` parameter (defaults to `true`).
|
||||
* If you are only interested in the raw form data, pass `unflatten: false` to the handler
|
||||
*/
|
||||
getFieldDefaultValue: <FieldType = unknown>(path: string) => FieldType | undefined;
|
||||
/** Return the form data. */
|
||||
getFormData: () => T;
|
||||
/* Returns an array with of all errors in the form. */
|
||||
getErrors: () => string[];
|
||||
|
@ -49,7 +46,20 @@ export interface FormHook<T extends FormData = FormData, I extends FormData = T>
|
|||
* Reset the form states to their initial value and optionally
|
||||
* all the fields to their initial values.
|
||||
*/
|
||||
reset: (options?: { resetValues?: boolean; defaultValue?: Partial<T> }) => void;
|
||||
reset: (options?: {
|
||||
/**
|
||||
* Flag to indicate if the fields values are reset or only the states
|
||||
* (isSubmitted, isPristine, isValidated...).
|
||||
* @default true
|
||||
*/
|
||||
resetValues?: boolean;
|
||||
/**
|
||||
* The defaultValue object of the form to reset to (if resetValues is "true").
|
||||
* If not specified, the initial "defaultValue" passed when initiating the form will be used.
|
||||
* Pass an empty object (`{}`) to reset to a blank form.
|
||||
*/
|
||||
defaultValue?: Partial<T>;
|
||||
}) => void;
|
||||
validateFields: (
|
||||
fieldNames: string[],
|
||||
/** Run only blocking validations */
|
||||
|
@ -57,12 +67,18 @@ export interface FormHook<T extends FormData = FormData, I extends FormData = T>
|
|||
) => Promise<{ areFieldsValid: boolean; isFormValid: boolean | undefined }>;
|
||||
readonly __options: Required<FormOptions>;
|
||||
__getFormData$: () => Subject<FormData>;
|
||||
__addField: (field: FieldHook) => void;
|
||||
__addField: (field: FieldHook<any>) => void;
|
||||
__removeField: (fieldNames: string | string[]) => void;
|
||||
__updateFormDataAt: (field: string, value: unknown) => void;
|
||||
__updateDefaultValueAt: (field: string, value: unknown) => void;
|
||||
__readFieldConfigFromSchema: (field: string) => FieldConfig;
|
||||
__getFormDefaultValue: () => FormData;
|
||||
__readFieldConfigFromSchema: <
|
||||
FieldType = unknown,
|
||||
FormType = FormData,
|
||||
InternalFieldType = FieldType
|
||||
>(
|
||||
fieldPath: string
|
||||
) => FieldConfig<FieldType, FormType, InternalFieldType> | undefined;
|
||||
__getFormDefaultValue: () => I | undefined;
|
||||
__getFieldsRemoved: () => FieldsMap;
|
||||
}
|
||||
|
||||
|
@ -116,9 +132,17 @@ export interface FieldHook<T = unknown, I = T> {
|
|||
readonly isValidating: boolean;
|
||||
readonly isValidated: boolean;
|
||||
readonly isChangingValue: boolean;
|
||||
/**
|
||||
* Validations declared on the field can have a specific "type" added (if not specified
|
||||
* the `field` type is set by default). When we validate a field, all errors are added into
|
||||
* a common "errors" array.
|
||||
* Use this handler to retrieve error messages for a specific error code or validation type.
|
||||
*/
|
||||
getErrorsMessages: (args?: {
|
||||
validationType?: 'field' | string;
|
||||
/** The errorCode to return error messages from. It takes precedence over "validationType" */
|
||||
errorCode?: string;
|
||||
/** The validation type to return error messages from */
|
||||
validationType?: 'field' | string;
|
||||
}) => string | null;
|
||||
/**
|
||||
* Form <input /> "onChange" event handler
|
||||
|
@ -129,11 +153,15 @@ export interface FieldHook<T = unknown, I = T> {
|
|||
/**
|
||||
* Handler to change the field value
|
||||
*
|
||||
* @param value The new value to assign to the field. If you provide a callback, you wil receive
|
||||
* the previous value and you need to return the next value.
|
||||
* @param value The new value to assign to the field. If a callback is provided, the new value
|
||||
* must be returned synchronously.
|
||||
*/
|
||||
setValue: (value: I | ((prevValue: I) => I)) => void;
|
||||
setErrors: (errors: ValidationError[]) => void;
|
||||
/**
|
||||
* Clear field errors. One of multiple validation types can be specified.
|
||||
* If no type is specified the default `field` type will be cleared.
|
||||
*/
|
||||
clearErrors: (type?: string | string[]) => void;
|
||||
/**
|
||||
* Validate a form field, running all its validations.
|
||||
|
@ -147,8 +175,10 @@ export interface FieldHook<T = unknown, I = T> {
|
|||
onlyBlocking?: boolean;
|
||||
}) => FieldValidateResponse | Promise<FieldValidateResponse>;
|
||||
reset: (options?: { resetValue?: boolean; defaultValue?: T }) => unknown | undefined;
|
||||
// Flag to indicate if the field value will be included in the form data outputted
|
||||
// when calling form.getFormData();
|
||||
/**
|
||||
* (Used internally). Flag to indicate if the field value will be included in the form data outputted
|
||||
* when submitting the form or calling `form.getFormData()`.
|
||||
*/
|
||||
__isIncludedInOutput: boolean;
|
||||
__serializeValue: (internalValue?: I) => T;
|
||||
}
|
||||
|
@ -160,13 +190,18 @@ export interface FieldConfig<T = unknown, FormType extends FormData = FormData,
|
|||
readonly type?: string;
|
||||
readonly defaultValue?: T;
|
||||
readonly validations?: Array<ValidationConfig<FormType, string, I>>;
|
||||
readonly formatters?: FormatterFunc[];
|
||||
readonly formatters?: Array<FormatterFunc<I>>;
|
||||
readonly deserializer?: SerializerFunc<I, T>;
|
||||
readonly serializer?: SerializerFunc<T, I>;
|
||||
readonly fieldsToValidateOnChange?: string[];
|
||||
readonly valueChangeDebounceTime?: number;
|
||||
}
|
||||
|
||||
export interface FieldValidationData {
|
||||
validationData?: unknown;
|
||||
validationDataProvider?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface FieldsMap {
|
||||
[key: string]: FieldHook;
|
||||
}
|
||||
|
@ -206,7 +241,7 @@ export type ValidationFunc<
|
|||
V = unknown
|
||||
> = (
|
||||
data: ValidationFuncArg<I, V>
|
||||
) => ValidationError<E> | void | undefined | ValidationCancelablePromise;
|
||||
) => ValidationError<E> | void | undefined | ValidationCancelablePromise<E>;
|
||||
|
||||
export type ValidationResponsePromise<E extends string = string> = Promise<
|
||||
ValidationError<E> | void | undefined
|
||||
|
@ -226,7 +261,7 @@ export interface FormData {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
type FormatterFunc = (value: any, formData: FormData) => unknown;
|
||||
type FormatterFunc<I = unknown> = (value: any, formData: FormData) => I;
|
||||
|
||||
// We set it as unknown as a form field can be any of any type
|
||||
// string | number | boolean | string[] ...
|
||||
|
|
|
@ -12,7 +12,7 @@ export type {
|
|||
FormHook,
|
||||
FieldHook,
|
||||
FormData,
|
||||
Props as UseFieldProps,
|
||||
UseFieldProps,
|
||||
FieldConfig,
|
||||
OnFormUpdateArg,
|
||||
ValidationFunc,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue