[Form lib] Improve code readability and comments (#126340)

This commit is contained in:
Sébastien Loix 2022-03-18 17:30:39 +00:00 committed by GitHub
parent 73e4069bed
commit bc19e0dd0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 819 additions and 374 deletions

View 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

View file

@ -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.*/}
![Field value change sequence diagram](form_lib_field_value_change.png)

View file

@ -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.

View file

@ -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]);
...
```

View file

@ -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" }
]
}
]
}

View file

@ -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;

View file

@ -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';

View file

@ -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

View file

@ -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>>

View file

@ -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) }],

View file

@ -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>(
() => ({

View file

@ -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;

View file

@ -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;

View file

@ -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];
};

View file

@ -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);

View file

@ -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>>();

View file

@ -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';

View file

@ -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[] ...

View file

@ -12,7 +12,7 @@ export type {
FormHook,
FieldHook,
FormData,
Props as UseFieldProps,
UseFieldProps,
FieldConfig,
OnFormUpdateArg,
ValidationFunc,