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