UNPKG

remix-validated-form

Version:

Form component and utils for easy form validation in remix

307 lines (306 loc) 14.2 kB
import { getPath, setPath } from "set-get"; import invariant from "tiny-invariant"; import create from "zustand"; import { immer } from "zustand/middleware/immer"; import { requestSubmit } from "../logic/requestSubmit"; import * as arrayUtil from "./arrayUtil"; const noOp = () => { }; const defaultFormState = { isHydrated: false, isSubmitting: false, hasBeenSubmitted: false, touchedFields: {}, fieldErrors: {}, formElement: null, isValid: () => true, startSubmit: noOp, endSubmit: noOp, setTouched: noOp, setFieldError: noOp, setFieldErrors: noOp, clearFieldError: noOp, currentDefaultValues: {}, reset: () => noOp, syncFormProps: noOp, setFormElement: noOp, validateField: async () => null, validate: async () => { throw new Error("Validate called before form was initialized."); }, submit: async () => { throw new Error("Submit called before form was initialized."); }, resetFormElement: noOp, getValues: () => new FormData(), controlledFields: { values: {}, refCounts: {}, valueUpdatePromises: {}, valueUpdateResolvers: {}, register: noOp, unregister: noOp, setValue: noOp, getValue: noOp, kickoffValueUpdate: noOp, awaitValueUpdate: async () => { throw new Error("AwaitValueUpdate called before form was initialized."); }, array: { push: noOp, swap: noOp, move: noOp, insert: noOp, unshift: noOp, remove: noOp, pop: noOp, replace: noOp, }, }, }; const createFormState = (set, get) => ({ // It's not "hydrated" until the form props are synced isHydrated: false, isSubmitting: false, hasBeenSubmitted: false, touchedFields: {}, fieldErrors: {}, formElement: null, currentDefaultValues: {}, isValid: () => Object.keys(get().fieldErrors).length === 0, startSubmit: () => set((state) => { state.isSubmitting = true; state.hasBeenSubmitted = true; }), endSubmit: () => set((state) => { state.isSubmitting = false; }), setTouched: (fieldName, touched) => set((state) => { state.touchedFields[fieldName] = touched; }), setFieldError: (fieldName, error) => set((state) => { state.fieldErrors[fieldName] = error; }), setFieldErrors: (errors) => set((state) => { state.fieldErrors = errors; }), clearFieldError: (fieldName) => set((state) => { delete state.fieldErrors[fieldName]; }), reset: () => set((state) => { var _a, _b; state.fieldErrors = {}; state.touchedFields = {}; state.hasBeenSubmitted = false; const nextDefaults = (_b = (_a = state.formProps) === null || _a === void 0 ? void 0 : _a.defaultValues) !== null && _b !== void 0 ? _b : {}; state.controlledFields.values = nextDefaults; state.currentDefaultValues = nextDefaults; }), syncFormProps: (props) => set((state) => { if (!state.isHydrated) { state.controlledFields.values = props.defaultValues; state.currentDefaultValues = props.defaultValues; } state.formProps = props; state.isHydrated = true; }), setFormElement: (formElement) => { // This gets called frequently, so we want to avoid calling set() every time // Or else we wind up with an infinite loop if (get().formElement === formElement) return; set((state) => { // weird type issue here // seems to be because formElement is a writable draft state.formElement = formElement; }); }, validateField: async (field) => { var _a, _b, _c; const formElement = get().formElement; invariant(formElement, "Cannot find reference to form. This is probably a bug in remix-validated-form."); const validator = (_a = get().formProps) === null || _a === void 0 ? void 0 : _a.validator; invariant(validator, "Cannot validator. This is probably a bug in remix-validated-form."); await ((_c = (_b = get().controlledFields).awaitValueUpdate) === null || _c === void 0 ? void 0 : _c.call(_b, field)); const { error } = await validator.validateField(new FormData(formElement), field); if (error) { get().setFieldError(field, error); return error; } else { get().clearFieldError(field); return null; } }, validate: async () => { var _a; const formElement = get().formElement; invariant(formElement, "Cannot find reference to form. This is probably a bug in remix-validated-form."); const validator = (_a = get().formProps) === null || _a === void 0 ? void 0 : _a.validator; invariant(validator, "Cannot validator. This is probably a bug in remix-validated-form."); const result = await validator.validate(new FormData(formElement)); if (result.error) get().setFieldErrors(result.error.fieldErrors); return result; }, submit: () => { const formElement = get().formElement; invariant(formElement, "Cannot find reference to form. This is probably a bug in remix-validated-form."); requestSubmit(formElement); }, getValues: () => { var _a; return new FormData((_a = get().formElement) !== null && _a !== void 0 ? _a : undefined); }, resetFormElement: () => { var _a; return (_a = get().formElement) === null || _a === void 0 ? void 0 : _a.reset(); }, controlledFields: { values: {}, refCounts: {}, valueUpdatePromises: {}, valueUpdateResolvers: {}, register: (fieldName) => { set((state) => { var _a; const current = (_a = state.controlledFields.refCounts[fieldName]) !== null && _a !== void 0 ? _a : 0; state.controlledFields.refCounts[fieldName] = current + 1; }); }, unregister: (fieldName) => { // For this helper in particular, we may run into a case where state is undefined. // When the whole form unmounts, the form state may be cleaned up before the fields are. if (get() === null || get() === undefined) return; set((state) => { var _a, _b, _c; const current = (_a = state.controlledFields.refCounts[fieldName]) !== null && _a !== void 0 ? _a : 0; if (current > 1) { state.controlledFields.refCounts[fieldName] = current - 1; return; } const isNested = Object.keys(state.controlledFields.refCounts).some((key) => fieldName.startsWith(key) && key !== fieldName); // When nested within a field array, we should leave resetting up to the field array if (!isNested) { setPath(state.controlledFields.values, fieldName, getPath((_b = state.formProps) === null || _b === void 0 ? void 0 : _b.defaultValues, fieldName)); setPath(state.currentDefaultValues, fieldName, getPath((_c = state.formProps) === null || _c === void 0 ? void 0 : _c.defaultValues, fieldName)); } delete state.controlledFields.refCounts[fieldName]; }); }, getValue: (fieldName) => getPath(get().controlledFields.values, fieldName), setValue: (fieldName, value) => { set((state) => { setPath(state.controlledFields.values, fieldName, value); }); get().controlledFields.kickoffValueUpdate(fieldName); }, kickoffValueUpdate: (fieldName) => { const clear = () => set((state) => { delete state.controlledFields.valueUpdateResolvers[fieldName]; delete state.controlledFields.valueUpdatePromises[fieldName]; }); set((state) => { const promise = new Promise((resolve) => { state.controlledFields.valueUpdateResolvers[fieldName] = resolve; }).then(clear); state.controlledFields.valueUpdatePromises[fieldName] = promise; }); }, awaitValueUpdate: async (fieldName) => { await get().controlledFields.valueUpdatePromises[fieldName]; }, array: { push: (fieldName, item) => { set((state) => { arrayUtil .getArray(state.controlledFields.values, fieldName) .push(item); arrayUtil.getArray(state.currentDefaultValues, fieldName).push(item); // New item added to the end, no need to update touched or error }); get().controlledFields.kickoffValueUpdate(fieldName); }, swap: (fieldName, indexA, indexB) => { set((state) => { arrayUtil.swap(arrayUtil.getArray(state.controlledFields.values, fieldName), indexA, indexB); arrayUtil.swap(arrayUtil.getArray(state.currentDefaultValues, fieldName), indexA, indexB); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => arrayUtil.swap(array, indexA, indexB)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => arrayUtil.swap(array, indexA, indexB)); }); get().controlledFields.kickoffValueUpdate(fieldName); }, move: (fieldName, from, to) => { set((state) => { arrayUtil.move(arrayUtil.getArray(state.controlledFields.values, fieldName), from, to); arrayUtil.move(arrayUtil.getArray(state.currentDefaultValues, fieldName), from, to); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => arrayUtil.move(array, from, to)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => arrayUtil.move(array, from, to)); }); get().controlledFields.kickoffValueUpdate(fieldName); }, insert: (fieldName, index, item) => { set((state) => { arrayUtil.insert(arrayUtil.getArray(state.controlledFields.values, fieldName), index, item); arrayUtil.insert(arrayUtil.getArray(state.currentDefaultValues, fieldName), index, item); // Even though this is a new item, we need to push around other items. arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => arrayUtil.insert(array, index, false)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => arrayUtil.insert(array, index, undefined)); }); get().controlledFields.kickoffValueUpdate(fieldName); }, remove: (fieldName, index) => { set((state) => { arrayUtil.remove(arrayUtil.getArray(state.controlledFields.values, fieldName), index); arrayUtil.remove(arrayUtil.getArray(state.currentDefaultValues, fieldName), index); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => arrayUtil.remove(array, index)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => arrayUtil.remove(array, index)); }); get().controlledFields.kickoffValueUpdate(fieldName); }, pop: (fieldName) => { set((state) => { arrayUtil.getArray(state.controlledFields.values, fieldName).pop(); arrayUtil.getArray(state.currentDefaultValues, fieldName).pop(); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => array.pop()); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => array.pop()); }); get().controlledFields.kickoffValueUpdate(fieldName); }, unshift: (fieldName, value) => { set((state) => { arrayUtil .getArray(state.controlledFields.values, fieldName) .unshift(value); arrayUtil .getArray(state.currentDefaultValues, fieldName) .unshift(value); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => array.unshift(false)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => array.unshift(undefined)); }); }, replace: (fieldName, index, item) => { set((state) => { arrayUtil.replace(arrayUtil.getArray(state.controlledFields.values, fieldName), index, item); arrayUtil.replace(arrayUtil.getArray(state.currentDefaultValues, fieldName), index, item); arrayUtil.mutateAsArray(fieldName, state.touchedFields, (array) => arrayUtil.replace(array, index, item)); arrayUtil.mutateAsArray(fieldName, state.fieldErrors, (array) => arrayUtil.replace(array, index, item)); }); get().controlledFields.kickoffValueUpdate(fieldName); }, }, }, }); export const useRootFormStore = create()(immer((set, get) => ({ forms: {}, form: (formId) => { var _a; return (_a = get().forms[formId]) !== null && _a !== void 0 ? _a : defaultFormState; }, cleanupForm: (formId) => { set((state) => { delete state.forms[formId]; }); }, registerForm: (formId) => { if (get().forms[formId]) return; set((state) => { state.forms[formId] = createFormState((setter) => set((state) => setter(state.forms[formId])), () => get().forms[formId]); }); }, })));