react-swift-form
Version:
React library fro form validation
638 lines (604 loc) • 18.2 kB
text/typescript
import type {
IError,
IFormContext,
IFormMode,
IFormRevalidateMode,
IFormValues,
IMessages,
IOnChangeHandlerParams,
IRegisterParams,
IResetHandler,
IStateSubscriber,
IStates,
ISubmitErrorHandler,
ISubmitHandler,
ITransformers,
IValidator,
IValidatorObject,
IWatchSubscriber,
} from '../types';
import type { FormEvent, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { initialError, initialStates } from '../constants';
import {
areObjectEquals,
filterObject,
getData,
getDefaultValues,
getFormInput,
getFormInputs,
getFormStates,
getLocalFields,
getName,
getTransformers,
getValidators,
getValue,
isCheckbox,
isFormElement,
isLocalValidator,
shouldBlur,
shouldChange,
validateForm,
} from '../helpers';
export interface IUseFormProps {
defaultValues?: Record<string, unknown>;
errorCallback?: (error: Error) => void;
filterLocalErrors?: boolean;
focusOnError?: boolean;
form?: HTMLFormElement;
messages?: IMessages;
mode?: IFormMode;
onBlurOptOut?: string[] | string;
onChangeOptOut?: string[] | string;
onReset?: IResetHandler;
onSubmit?: ISubmitHandler;
onSubmitError?: ISubmitErrorHandler;
revalidateMode?: IFormRevalidateMode;
transformers?: ITransformers;
useNativeValidation?: boolean;
validators?: Record<string, IValidator | IValidatorObject>;
}
export interface INativeFormProps {
noValidate: boolean;
onChange: (event: FormEvent<HTMLFormElement>) => void;
onReset: (event: FormEvent<HTMLFormElement>) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
ref: RefObject<HTMLFormElement>;
}
export interface IUseFormResult extends IFormContext {
formProps: INativeFormProps;
}
export function useForm(props: IUseFormProps = {}): IUseFormResult {
const {
defaultValues,
errorCallback = console.error,
filterLocalErrors = true,
focusOnError = true,
form = null,
messages,
mode = 'submit',
onBlurOptOut,
onChangeOptOut,
onReset,
onSubmit,
onSubmitError,
revalidateMode = 'submit',
transformers,
useNativeValidation = true,
validators,
} = props;
const ref = useRef<HTMLFormElement>(form);
const fields = useRef<Set<IRegisterParams>>(new Set());
const defaultVals = useRef<IFormValues>(defaultValues ?? {});
const prevVals = useRef<IFormValues>({});
const vals = useRef<IFormValues>({});
const resetVals = useRef<IFormValues | null | undefined>(null);
const manualErrors = useRef<Record<string, string | null>>({});
const changeNames = useRef<Record<string, boolean>>({});
const changeHandlerInitializers = useRef<Record<string, () => void>>({});
const states = useRef<IStates>({
...initialStates,
changedFields: new Set<string>(),
isReady: false,
touchedFields: new Set<string>(),
});
const prevErrors = useRef<IError>(initialError);
const [errors, setErrors] = useState<IError>(initialError);
// State observer
const stateSubscribers = useRef<Set<IStateSubscriber>>(new Set());
const stateSubscribe = useCallback((subscriber: IStateSubscriber) => {
if (!stateSubscribers.current.has(subscriber)) {
stateSubscribers.current.add(subscriber);
}
return () => stateSubscribers.current.delete(subscriber);
}, []);
const stateNotify = useCallback(() => {
for (const subscriber of stateSubscribers.current.values()) {
subscriber(
getFormStates(
states.current,
vals.current,
defaultVals.current,
ref.current,
),
);
}
}, []);
// Value observer (watch)
const watchSubscriber = useRef<Map<IWatchSubscriber, string[] | undefined>>(
new Map(),
);
const watchSubscribe = useCallback(
(subscriber: IWatchSubscriber, names?: string[] | string) => {
const nameArray =
names === undefined ? names : names instanceof Array ? names : [names];
watchSubscriber.current.set(subscriber, nameArray);
return () => watchSubscriber.current.delete(subscriber);
},
[],
);
const watchNotify = useCallback(() => {
if (ref.current) {
const newValues = getData({
form: ref.current,
transformers: getTransformers(fields.current, transformers),
values: vals.current,
});
for (const [subscriber, names] of watchSubscriber.current.entries()) {
const newFilteredValues = filterObject(
newValues,
([name]) => !names || names.includes(name),
);
const prevFilteredValues = filterObject(
prevVals.current,
([name]) => !names || names.includes(name),
);
subscriber({
names,
prevValues: prevFilteredValues,
values: newFilteredValues,
});
}
prevVals.current = { ...newValues };
}
}, [transformers]);
const validate = useCallback(
async (
display = false,
revalidate = false,
focusOnError = false,
names?: string[] | string | null,
): Promise<[boolean, IError]> => {
if (!ref.current) {
return [false, initialError];
}
states.current.isValidating = true;
stateNotify();
let errors;
try {
const localFields = getLocalFields(fields.current);
const validatorArray = getValidators(
fields.current,
validators,
messages,
);
// Validate
errors = await validateForm({
display,
errors: manualErrors.current,
filterLocalErrors,
focusOnError,
form: ref.current,
localFields,
messages,
names: names instanceof Array ? names : names ? [names] : undefined,
revalidate,
setErrors,
transformers: getTransformers(fields.current, transformers),
useNativeValidation,
validators: validatorArray,
values: vals.current,
});
} finally {
states.current.isValidating = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
states.current.isValid = Boolean(ref.current?.checkValidity());
stateNotify();
}
return [states.current.isValid, errors];
},
[
filterLocalErrors,
messages,
stateNotify,
useNativeValidation,
transformers,
validators,
],
);
const timer = useRef<NodeJS.Timeout>();
const debouncedValidate = useCallback(
(
display?: boolean,
revalidate?: boolean,
focusOnError?: boolean,
names?: string[] | string | null,
) => {
clearTimeout(timer.current);
timer.current = setTimeout(
() => void validate(display, revalidate, focusOnError, names),
0,
);
},
[validate],
);
useEffect(() => () => clearTimeout(timer.current), []); // Unmount timer clear
const setValues = useCallback(
(values: IFormValues) => {
if (ref.current) {
for (const input of getFormInputs(ref.current)) {
const formField = getFormInput(input);
const { name } = formField;
if (name && values[name] !== undefined && values[name] !== null) {
if (isCheckbox(formField)) {
formField.checked = Boolean(values[name]);
} else {
formField.value = String(values[name]);
}
}
}
watchNotify();
}
},
[watchNotify],
);
const register = useCallback(
(params: IRegisterParams) => {
fields.current.add(params);
if (params.defaultValues) {
defaultVals.current = {
...defaultVals.current,
...params.defaultValues,
};
setValues(params.defaultValues);
}
debouncedValidate();
},
[debouncedValidate, setValues],
);
const unregister = useCallback(
(params: IRegisterParams) => {
fields.current.delete(params);
debouncedValidate();
},
[debouncedValidate],
);
const resetForm = useCallback(
(paramValues?: IFormValues | null | void) => {
// Reset errors.
setErrors(initialError);
const validatorArray = getValidators(
fields.current,
validators,
messages,
);
for (const params of validatorArray) {
if (isLocalValidator(params)) {
params.setErrors(initialError);
}
}
// Reset states
states.current = {
...initialStates,
changedFields: new Set<string>(),
isReady: states.current.isReady,
touchedFields: new Set<string>(),
};
// Reset values
vals.current = {};
defaultVals.current = getDefaultValues(
fields.current,
defaultValues,
paramValues,
resetVals.current,
);
resetVals.current = null;
for (const init of Object.values(changeHandlerInitializers.current)) {
init();
}
setTimeout(() => {
setValues(defaultVals.current);
debouncedValidate();
});
},
[debouncedValidate, defaultValues, messages, setValues, validators],
);
const reset = useCallback((values?: IFormValues | null) => {
resetVals.current = values;
ref.current?.reset();
}, []);
const change = useCallback(
<V>(value: unknown, name?: string): [string, V] => {
const fieldName = getName(value) ?? name;
if (!fieldName) {
throw new Error(
'react-swift-form was unable to retrieve the field name',
);
}
states.current.changedFields.add(fieldName);
const allTransformers = getTransformers(fields.current, transformers);
const val = getValue<V>(value, allTransformers?.[fieldName]);
vals.current[fieldName] = val;
watchNotify();
debouncedValidate(
mode === 'all' || mode === 'change',
revalidateMode === 'change',
false,
fieldName,
);
return [fieldName, val];
},
[debouncedValidate, mode, revalidateMode, transformers, watchNotify],
);
let submitted = false;
const submit = useCallback(
async (
event: FormEvent<HTMLFormElement>,
validCallback?: ISubmitHandler,
invalidCallback?: ISubmitErrorHandler,
) => {
// Flag top prevent submit infinite loop
if (submitted) {
return;
}
// We have to prevent the event (even for server actions) because we need to await the validation
event.preventDefault();
try {
states.current.isSubmitting = true;
const [isValid, errors] = await validate(true, false, focusOnError);
if (isValid && ref.current) {
// If the form action is not null then it should be a server action
// In that case re-submit the form (set flag to true before to avoid infinite loop)
if (ref.current.getAttribute('action') !== null) {
// eslint-disable-next-line react-hooks/exhaustive-deps
submitted = true;
setTimeout(() => {
// We need to call requestSubmit in setTimeout for it to work as expected
ref.current?.requestSubmit();
submitted = false;
});
}
if (validCallback) {
await validCallback(
event,
getData({
form: ref.current,
transformers: getTransformers(fields.current, transformers),
values: vals.current,
}),
reset,
);
}
} else if (invalidCallback) {
await invalidCallback(event, errors, reset);
}
} catch (error) {
errorCallback(error);
} finally {
states.current.isSubmitting = false;
states.current.submitCount++;
stateNotify();
}
},
[focusOnError, reset, transformers, validate],
);
const handleChange = useCallback(
(event: FormEvent<HTMLFormElement>) => {
const name = getName(event);
if (name && changeNames.current[name]) {
// Prevent double call to the change function when the onChange handler is used
// The handler is called first and then the event propagates
// But we can't always prevent it because sometimes we don't have access to the event object
// Like with the MUI Datepicker
changeNames.current[name] = false;
} else if (shouldChange(fields.current, name, onChangeOptOut)) {
change(event);
}
},
[change, onChangeOptOut],
);
const handleReset = useCallback(
(event: FormEvent<HTMLFormElement>) => {
const resetValues = onReset?.(event, vals.current);
resetForm(resetValues);
},
[onReset, resetForm],
);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
return submit(event, onSubmit, onSubmitError);
},
[onSubmit, onSubmitError, submit],
);
useEffect(() => {
// Init default values
defaultVals.current = { ...defaultVals.current, ...defaultValues };
for (const init of Object.values(changeHandlerInitializers.current)) {
init();
}
setValues(defaultVals.current);
}, [defaultValues, setValues]);
useEffect(() => {
// Prevent re-validating when we just update the errors state
if (prevErrors.current !== errors) {
prevErrors.current = errors;
return;
}
// Do not call debounceValidate because when don't want this call to cancel a previous debounceValidate call
// For example when we change a value and mode=change (in that case render=true but in the useEffect render=false)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
validate().then(() => {
if (ref.current) {
states.current.isReady = true;
stateNotify();
ref.current.dataset.rsf = 'init';
}
});
});
// Manage blur event listeners
useEffect(() => {
if (ref.current) {
const form = ref.current;
const handleFocusOut = (event: FocusEvent): void => {
if (event.target && isFormElement(event.target)) {
states.current.touchedFields.add(event.target.name);
if (
(mode === 'all' || mode === 'blur' || revalidateMode === 'blur') &&
shouldBlur(fields.current, event.target.name, onBlurOptOut)
) {
void validate(
mode === 'all' || mode === 'blur',
revalidateMode === 'blur',
false,
event.target.name,
);
} else {
stateNotify();
}
}
};
form.addEventListener('focusout', handleFocusOut);
return () => form.removeEventListener('focusout', handleFocusOut);
}
return undefined;
}, [mode, onBlurOptOut, revalidateMode, stateNotify, validate]);
const onErrorHandler = useCallback(
(name: string) => {
return (manualError: string | null) => {
manualErrors.current[name] = manualError;
debouncedValidate(
mode === 'all' || mode === 'change',
revalidateMode === 'change',
false,
name,
);
};
},
[debouncedValidate, mode, revalidateMode],
);
const onChangeHandler = useCallback(
<V, T extends unknown[] = unknown[]>(
callback: (value: V, ...args: T) => void,
params: IOnChangeHandlerParams<V, T> = {},
) => {
const { getError, name } = params;
if (name) {
// Value initializer
const init = (): void => {
if (
vals.current[name] === undefined &&
defaultVals.current[name] !== undefined
) {
vals.current[name] = defaultVals.current[name];
}
};
changeHandlerInitializers.current[name] = init;
init();
}
return (value: unknown, ...args: T): void => {
const [fieldName, val] = change<V>(value, name);
changeNames.current[fieldName] = true;
if (getError) {
onErrorHandler(fieldName)(getError(val, ...args));
}
callback(val, ...args);
};
},
[change, onErrorHandler],
);
const onResetHandler = useCallback(
(callback?: IResetHandler) => {
return (event: FormEvent<HTMLFormElement>) => {
const resetValues = callback?.(event, vals.current);
resetForm(resetValues);
};
},
[resetForm],
);
const onSubmitHandler = useCallback(
(validCallback?: ISubmitHandler, invalidCallback?: ISubmitErrorHandler) => {
return async (event: FormEvent<HTMLFormElement>) => {
return submit(event, validCallback, invalidCallback);
};
},
[submit],
);
const watch = useCallback(
<V extends IFormValues>(
callback: (values: V) => void,
names?: string[] | string,
) => {
return watchSubscribe(({ prevValues, values }) => {
if (!areObjectEquals(values, prevValues)) {
callback(values as V);
}
}, names);
},
[watchSubscribe],
);
const formProps = useMemo(
() => ({
noValidate: !useNativeValidation,
onChange: handleChange,
onReset: handleReset,
onSubmit: handleSubmit,
ref,
}),
[handleChange, handleReset, handleSubmit, useNativeValidation],
);
return useMemo(
() => ({
errors,
form: ref,
formProps,
messages,
mode,
onChange: onChangeHandler,
onError: onErrorHandler,
onReset: onResetHandler,
onSubmit: onSubmitHandler,
register,
reset,
revalidateMode,
states: getFormStates(
states.current,
vals.current,
defaultVals.current,
ref.current,
),
subscribe: stateSubscribe,
unregister,
useNativeValidation,
validate,
watch,
}),
[
errors,
formProps,
messages,
mode,
onChangeHandler,
onErrorHandler,
onResetHandler,
onSubmitHandler,
register,
reset,
revalidateMode,
stateSubscribe,
unregister,
useNativeValidation,
validate,
watch,
],
);
}