UNPKG

remix-validated-form

Version:

Form component and utils for easy form validation in remix

211 lines (210 loc) 9.66 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { Form as RemixForm, useSubmit, } from "@remix-run/react"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import * as R from "remeda"; import { useIsSubmitting, useIsValid } from "./hooks"; import { FORM_ID_FIELD } from "./internal/constants"; import { InternalFormContext, } from "./internal/formContext"; import { useDefaultValuesFromLoader, useErrorResponseForForm, useHasActiveFormSubmit, useSetFieldErrors, } from "./internal/hooks"; import { useMultiValueMap } from "./internal/MultiValueMap"; import { useRootFormStore, } from "./internal/state/createFormStore"; import { useFormStore } from "./internal/state/storeHooks"; import { useSubmitComplete } from "./internal/submissionCallbacks"; import { mergeRefs, useDeepEqualsMemo, useIsomorphicLayoutEffect as useLayoutEffect, } from "./internal/util"; const getDataFromForm = (el) => new FormData(el); function nonNull(value) { return value !== null; } const focusFirstInvalidInput = (fieldErrors, customFocusHandlers, formElement) => { var _a; const namesInOrder = [...formElement.elements] .map((el) => { const input = el instanceof RadioNodeList ? el[0] : el; if (input instanceof HTMLElement && "name" in input) return input.name; return null; }) .filter(nonNull) .filter((name) => name in fieldErrors); const uniqueNamesInOrder = R.uniq(namesInOrder); for (const fieldName of uniqueNamesInOrder) { if (customFocusHandlers.has(fieldName)) { customFocusHandlers.getAll(fieldName).forEach((handler) => { handler(); }); break; } const elem = formElement.elements.namedItem(fieldName); if (!elem) continue; if (elem instanceof RadioNodeList) { const selectedRadio = (_a = [...elem] .filter((item) => item instanceof HTMLInputElement) .find((item) => item.value === elem.value)) !== null && _a !== void 0 ? _a : elem[0]; if (selectedRadio && selectedRadio instanceof HTMLInputElement) { selectedRadio.focus(); break; } } if (elem instanceof HTMLElement) { if (elem instanceof HTMLInputElement && elem.type === "hidden") { continue; } elem.focus(); break; } } }; const useFormId = (providedId) => { // We can use a `Symbol` here because we only use it after hydration const [symbolId] = useState(() => Symbol("remix-validated-form-id")); return providedId !== null && providedId !== void 0 ? providedId : symbolId; }; /** * Use a component to access the state so we don't cause * any extra rerenders of the whole form. */ const FormResetter = ({ resetAfterSubmit, formRef, }) => { const isSubmitting = useIsSubmitting(); const isValid = useIsValid(); useSubmitComplete(isSubmitting, () => { var _a; if (isValid && resetAfterSubmit) { (_a = formRef.current) === null || _a === void 0 ? void 0 : _a.reset(); } }); return null; }; function formEventProxy(event) { let defaultPrevented = false; return new Proxy(event, { get: (target, prop) => { if (prop === "preventDefault") { return () => { defaultPrevented = true; }; } if (prop === "defaultPrevented") { return defaultPrevented; } return target[prop]; }, }); } /** * The primary form component of `remix-validated-form`. */ export function ValidatedForm({ validator, onSubmit, children, fetcher, action, defaultValues: unMemoizedDefaults, formRef: formRefProp, onReset, subaction, resetAfterSubmit = false, disableFocusOnError, method, replace, id, ...rest }) { var _a; const formId = useFormId(id); const providedDefaultValues = useDeepEqualsMemo(unMemoizedDefaults); const contextValue = useMemo(() => ({ formId, action, subaction, defaultValuesProp: providedDefaultValues, fetcher, }), [action, fetcher, formId, providedDefaultValues, subaction]); const backendError = useErrorResponseForForm(contextValue); const backendDefaultValues = useDefaultValuesFromLoader(contextValue); const hasActiveSubmission = useHasActiveFormSubmit(contextValue); const formRef = useRef(null); const Form = (_a = fetcher === null || fetcher === void 0 ? void 0 : fetcher.Form) !== null && _a !== void 0 ? _a : RemixForm; const submit = useSubmit(); const setFieldErrors = useSetFieldErrors(formId); const setFieldError = useFormStore(formId, (state) => state.setFieldError); const reset = useFormStore(formId, (state) => state.reset); const startSubmit = useFormStore(formId, (state) => state.startSubmit); const endSubmit = useFormStore(formId, (state) => state.endSubmit); const syncFormProps = useFormStore(formId, (state) => state.syncFormProps); const setFormElementInState = useFormStore(formId, (state) => state.setFormElement); const cleanupForm = useRootFormStore((state) => state.cleanupForm); const registerForm = useRootFormStore((state) => state.registerForm); const customFocusHandlers = useMultiValueMap(); const registerReceiveFocus = useCallback((fieldName, handler) => { customFocusHandlers().add(fieldName, handler); return () => { customFocusHandlers().remove(fieldName, handler); }; }, [customFocusHandlers]); // TODO: all these hooks running at startup cause extra, unnecessary renders // There must be a nice way to avoid this. useLayoutEffect(() => { registerForm(formId); return () => cleanupForm(formId); }, [cleanupForm, formId, registerForm]); useLayoutEffect(() => { var _a; syncFormProps({ action, defaultValues: (_a = providedDefaultValues !== null && providedDefaultValues !== void 0 ? providedDefaultValues : backendDefaultValues) !== null && _a !== void 0 ? _a : {}, subaction, registerReceiveFocus, validator, }); }, [ action, providedDefaultValues, registerReceiveFocus, subaction, syncFormProps, backendDefaultValues, validator, ]); useLayoutEffect(() => { setFormElementInState(formRef.current); }, [setFormElementInState]); useEffect(() => { var _a; setFieldErrors((_a = backendError === null || backendError === void 0 ? void 0 : backendError.fieldErrors) !== null && _a !== void 0 ? _a : {}); }, [backendError === null || backendError === void 0 ? void 0 : backendError.fieldErrors, setFieldErrors, setFieldError]); useSubmitComplete(hasActiveSubmission, () => { endSubmit(); }); const handleSubmit = async (e, target, nativeEvent) => { startSubmit(); const submitter = nativeEvent.submitter; const formDataToValidate = getDataFromForm(e.currentTarget); if (submitter === null || submitter === void 0 ? void 0 : submitter.name) { formDataToValidate.append(submitter.name, submitter.value); } const result = await validator.validate(formDataToValidate); if (result.error) { setFieldErrors(result.error.fieldErrors); endSubmit(); if (!disableFocusOnError) { focusFirstInvalidInput(result.error.fieldErrors, customFocusHandlers(), formRef.current); } } else { setFieldErrors({}); const eventProxy = formEventProxy(e); await (onSubmit === null || onSubmit === void 0 ? void 0 : onSubmit(result.data, eventProxy)); if (eventProxy.defaultPrevented) { endSubmit(); return; } // We deviate from the remix code here a bit because of our async submit. // In remix's `FormImpl`, they use `event.currentTarget` to get the form, // but we already have the form in `formRef.current` so we can just use that. // If we use `event.currentTarget` here, it will break because `currentTarget` // will have changed since the start of the submission. if (fetcher) fetcher.submit(submitter || e.currentTarget); else submit(submitter || target, { replace, method: (submitter === null || submitter === void 0 ? void 0 : submitter.formMethod) || method, }); } }; return (_jsx(Form, { ref: mergeRefs([formRef, formRefProp]), ...rest, id: id, action: action, method: method, replace: replace, onSubmit: (e) => { e.preventDefault(); handleSubmit(e, e.currentTarget, e.nativeEvent); }, onReset: (event) => { onReset === null || onReset === void 0 ? void 0 : onReset(event); if (event.defaultPrevented) return; reset(); }, children: _jsx(InternalFormContext.Provider, { value: contextValue, children: _jsxs(_Fragment, { children: [_jsx(FormResetter, { formRef: formRef, resetAfterSubmit: resetAfterSubmit }), subaction && (_jsx("input", { type: "hidden", value: subaction, name: "subaction" })), id && _jsx("input", { type: "hidden", value: id, name: FORM_ID_FIELD }), children] }) }) })); }