remix-validated-form
Version:
Form component and utils for easy form validation in remix
211 lines (210 loc) • 9.66 kB
JavaScript
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] }) }) }));
}