diff --git a/README.md b/README.md index 5debe390..27f968ae 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,14 @@ type MyInputProps = { }; export const MyInput = ({ name, label }: InputProps) => { - const { validate, clearError, defaultValue, error } = useField(name); + const { error, getInputProps } = useField(name); return (
- - {error && {error}} + + {error && ( + {error} + )}
); }; diff --git a/apps/docs/app/components/FormInput.tsx b/apps/docs/app/components/FormInput.tsx index 4debcbeb..0a5b7dc1 100644 --- a/apps/docs/app/components/FormInput.tsx +++ b/apps/docs/app/components/FormInput.tsx @@ -20,8 +20,7 @@ export const FormInput: FC< onChange, ...rest }) => { - const { error, clearError, validate, defaultValue } = - useField(name); + const { error, getInputProps } = useField(name); return (
@@ -40,22 +39,18 @@ export const FormInput: FC<
{ - if (error) clearError(); - onChange?.(event); - }} - onBlur={validate} - defaultValue={defaultValue} - {...rest} + {...getInputProps({ + onChange, + id: name, + type: "text", + className: classNames( + "border focus:ring-teal-500 focus:border-teal-500 focus:z-10 block w-full sm:text-sm text-black pr-10", + "rounded-md p-2", + error && + "border-red-800 bg-red-50 text-red-800 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500" + ), + ...rest, + })} /> {error && (
diff --git a/apps/docs/app/components/Layout.tsx b/apps/docs/app/components/Layout.tsx index 6a102677..dbd306c0 100644 --- a/apps/docs/app/components/Layout.tsx +++ b/apps/docs/app/components/Layout.tsx @@ -1,6 +1,11 @@ import { MenuAlt2Icon } from "@heroicons/react/outline"; -import React, { FC, Fragment, useState } from "react"; -import { useMatches } from "remix"; +import React, { + FC, + Fragment, + useEffect, + useState, +} from "react"; +import { useLocation, useMatches } from "remix"; import { Sidebar } from "../components/Sidebar"; import { Footer } from "./Footer"; @@ -61,6 +66,13 @@ const navSections: Section[] = [ ]; export const Layout: FC = ({ children }) => { + const location = useLocation(); + const focusedProp = location.hash.replace("#", ""); + useEffect(() => { + const el = document.getElementById(focusedProp); + if (el) el.scrollIntoView(); + }, [focusedProp]); + const [sidebarOpen, setSidebarOpen] = useState(false); const matches = useMatches(); diff --git a/apps/docs/app/components/PropHeader.tsx b/apps/docs/app/components/PropHeader.tsx index a3a362cf..d7f751a0 100644 --- a/apps/docs/app/components/PropHeader.tsx +++ b/apps/docs/app/components/PropHeader.tsx @@ -1,4 +1,6 @@ +import { LinkIcon } from "@heroicons/react/outline"; import classNames from "classnames"; +import { useLocation } from "remix"; type PropHeaderProps = { variant?: "h1" | "h3"; @@ -23,14 +25,27 @@ export const PropHeader = ({ optional, variant: Variant = "h3", }: PropHeaderProps) => { + const location = useLocation(); + const focusedProp = location.hash.replace("#", ""); return (
- {prop} + + + {prop} +
diff --git a/apps/docs/app/routes/integrate-your-components.mdx b/apps/docs/app/routes/integrate-your-components.mdx index 731fa247..2ea455b0 100644 --- a/apps/docs/app/routes/integrate-your-components.mdx +++ b/apps/docs/app/routes/integrate-your-components.mdx @@ -21,6 +21,10 @@ so these features only work if you use it in a `ValidatedForm`. However, it is still safe to use your input outside of a `ValidatedForm`, it just won't be able to take advantage of the features. +If this example doesn't validate at the right times for your use-case +or you don't have a plain input, +check out the [useField](/reference/use-field) documentation to see what other helpers and options are available. + ```tsx import { useField } from "remix-validated-form"; @@ -30,18 +34,11 @@ type MyInputProps = { }; export const MyInput = ({ name, label }: InputProps) => { - const { validate, clearError, defaultValue, error } = - useField(name); + const { error, getInputProps } = useField(name); return (
- + {error && ( {error} )} diff --git a/apps/docs/app/routes/reference/use-field.mdx b/apps/docs/app/routes/reference/use-field.mdx index 3350d955..7e9db353 100644 --- a/apps/docs/app/routes/reference/use-field.mdx +++ b/apps/docs/app/routes/reference/use-field.mdx @@ -3,6 +3,7 @@ meta: title: useField (Remix Validated Form) --- +import outdent from "outdent"; import { PropHeader } from "~/components/PropHeader"; (props?: T) => T`} +/> + +A prop-getter used to get the props for the input. +This will automatically set up the validation behavior based on the [`validationBehavior`](#validationBehavior) option. + The default value of the field, if there is one. @@ -36,8 +45,72 @@ Clears the error message. Validates the form field and populates the `error` prop if there is an error. + + +Whether or not the field has been touched. +If you're using `getInputProps`, a field will become touched on blur. + + + +Sets the `touched` state of the field to whatever you pass in. + ## UseFieldOptions + + +Allows you to configure when the field should validate. +The keys (`initial`, `whenTouched`, `whenSubmitted`) are states the field/form could be in, +and the values (`onChange`, `onBlur`, `onSubmit`) are when you want to validate while in that state. + +**Note:** This behavior only applies when you are using `getInputProps`. +If you're managing the validation manually (using `validate`, `clearError`, `setTouched`, etc), +this will have no effect. + +#### Field/form states + +- `initial` + - The field has not been touched and the form has not been submitted. +- `whenTouched` + - The user touched the field. A field becomes touched on blur, or when you call `setTouched(true)`. +- `whenSubmitted` + - The user attempted to submit the form. + +#### When to validate + +- `onChange` + - Validates every time the `onChange` event of the input fires. +- `onBlur` + - Validates every time the `onBlur` event of the input fires. + - Clears the error message when the user starts typing in that field again. +- `onSubmit` + - The field does not validate itself at all and only validates when the form is submitted. + +No matter which option you choose, the form will always be validated again on submit. + +#### Default configuration + +By default the field will validate on blur, +then validate on every change after the field has been touched our submitted. + +``` +{ + initial: "onBlur", + whenTouched: "onChange", + whenSubmitted: "onChange", +} +``` + -Whether or not the form is submitting. +Whether or not the form is currently submitting. + + + +Whether or not a submit has been attempted. +This will be `true` after the first submit attempt, even if the form is invalid. +This resets when the form resets. + +Contains all the touched fields. +The keys of the object are the field names and values are whether or not the field has been touched. +If a field has not been touched at all, the value will be `undefined`. +If you set touched to `false` manually, then the value will be `false`. + + + +Manually set the `touched` state of the specified field. diff --git a/apps/docs/package.json b/apps/docs/package.json index 29f15006..8717c7d7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -27,6 +27,7 @@ "framer-motion": "^5.5.5", "highlight.js": "^11.3.1", "lodash": "^4.17.21", + "outdent": "^0.8.0", "react": "^17.0.2", "react-dom": "^17.0.2", "rehype-highlight": "^5.0.1", diff --git a/apps/sample-app/app/components/FormInput.tsx b/apps/sample-app/app/components/FormInput.tsx index c2340223..8de70cce 100644 --- a/apps/sample-app/app/components/FormInput.tsx +++ b/apps/sample-app/app/components/FormInput.tsx @@ -19,19 +19,17 @@ export const FormInput = ({ isRequired, ...rest }: FormInputProps & InputProps) => { - const { validate, clearError, defaultValue, error } = useField(name); + const { getInputProps, error } = useField(name); return ( <> {label} {error && {error}} diff --git a/apps/sample-app/app/components/FormSelect.tsx b/apps/sample-app/app/components/FormSelect.tsx index 13fc0d1d..a3734656 100644 --- a/apps/sample-app/app/components/FormSelect.tsx +++ b/apps/sample-app/app/components/FormSelect.tsx @@ -19,17 +19,15 @@ export const FormSelect = ({ isRequired, ...rest }: FormSelectProps & SelectProps) => { - const { validate, clearError, defaultValue, error } = useField(name); + const { getInputProps, error } = useField(name); return ( {label} + {error && {error}}
); diff --git a/apps/test-app/app/components/InputWithTouched.tsx b/apps/test-app/app/components/InputWithTouched.tsx new file mode 100644 index 00000000..ef2c9051 --- /dev/null +++ b/apps/test-app/app/components/InputWithTouched.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef } from "react"; +import { useField } from "remix-validated-form"; + +type InputProps = { + name: string; + label: string; +}; + +export const InputWithTouched = forwardRef( + ({ name, label }: InputProps, ref: React.ForwardedRef) => { + const { validate, clearError, defaultValue, error, touched, setTouched } = + useField(name); + return ( +
+ + { + setTouched(true); + validate(); + }} + onChange={() => { + if (touched) validate(); + else clearError(); + }} + defaultValue={defaultValue} + ref={ref} + /> + {touched && {name} touched} + {error && {error}} +
+ ); + } +); diff --git a/apps/test-app/app/routes/submission.aftersubmit.tsx b/apps/test-app/app/routes/submission.aftersubmit.tsx index 21b11f21..a5b6682e 100644 --- a/apps/test-app/app/routes/submission.aftersubmit.tsx +++ b/apps/test-app/app/routes/submission.aftersubmit.tsx @@ -6,7 +6,10 @@ import * as yup from "yup"; import { Input } from "~/components/Input"; import { SubmitButton } from "~/components/SubmitButton"; -const schema = yup.object({}); +const schema = yup.object({ + testinput: yup.string(), + anotherinput: yup.string(), +}); const validator = withYup(schema); export const action: ActionFunction = async ({ request }) => { diff --git a/apps/test-app/app/routes/submission.hasbeensubmitted.tsx b/apps/test-app/app/routes/submission.hasbeensubmitted.tsx new file mode 100644 index 00000000..eab4f150 --- /dev/null +++ b/apps/test-app/app/routes/submission.hasbeensubmitted.tsx @@ -0,0 +1,27 @@ +import { withYup } from "@remix-validated-form/with-yup"; +import { useFormContext, ValidatedForm } from "remix-validated-form"; +import * as yup from "yup"; +import { Input } from "~/components/Input"; +import { SubmitButton } from "~/components/SubmitButton"; + +const schema = yup.object({ + firstName: yup.string().label("First Name").required(), +}); + +const validator = withYup(schema); + +const IsSubmitted = () => { + const { hasBeenSubmitted } = useFormContext(); + return hasBeenSubmitted ?

Submitted!

: null; +}; + +export default function FrontendValidation() { + return ( + + + + + + + ); +} diff --git a/apps/test-app/app/routes/touched-state.tsx b/apps/test-app/app/routes/touched-state.tsx new file mode 100644 index 00000000..21511dc1 --- /dev/null +++ b/apps/test-app/app/routes/touched-state.tsx @@ -0,0 +1,21 @@ +import { withYup } from "@remix-validated-form/with-yup"; +import { ValidatedForm } from "remix-validated-form"; +import * as yup from "yup"; +import { InputWithTouched } from "~/components/InputWithTouched"; + +const schema = yup.object({ + firstName: yup.string(), + lastName: yup.string(), +}); + +const validator = withYup(schema); + +export default function FrontendValidation() { + return ( + + + + + + ); +} diff --git a/apps/test-app/app/routes/validation-fetcher.tsx b/apps/test-app/app/routes/validation-fetcher.tsx index 6ae84c74..a316eddb 100644 --- a/apps/test-app/app/routes/validation-fetcher.tsx +++ b/apps/test-app/app/routes/validation-fetcher.tsx @@ -26,9 +26,9 @@ export default function FrontendValidation() { return ( {fetcher.data?.message &&

{fetcher.data.message}

} - - - + + +
); diff --git a/apps/test-app/app/routes/validation-nofocus.tsx b/apps/test-app/app/routes/validation-nofocus.tsx index 183cf385..3628dc9b 100644 --- a/apps/test-app/app/routes/validation-nofocus.tsx +++ b/apps/test-app/app/routes/validation-nofocus.tsx @@ -25,10 +25,10 @@ export default function FrontendValidation() { return ( {actionData &&

{actionData.message}

} - - - - + + + +
); diff --git a/apps/test-app/app/routes/validation.tsx b/apps/test-app/app/routes/validation.tsx index b7973836..0acc0c00 100644 --- a/apps/test-app/app/routes/validation.tsx +++ b/apps/test-app/app/routes/validation.tsx @@ -33,10 +33,10 @@ export default function FrontendValidation() { return ( {actionData &&

{actionData.message}

} - - - - + + + +
diff --git a/apps/test-app/cypress/integration/submission.ts b/apps/test-app/cypress/integration/submission.ts index dfce47f9..16029cee 100644 --- a/apps/test-app/cypress/integration/submission.ts +++ b/apps/test-app/cypress/integration/submission.ts @@ -74,4 +74,16 @@ describe("Validation", () => { cy.findByText("Submit").click(); cy.findByLabelText("Test input").should("have.value", "noreset"); }); + + it("should track whether or not submission has been attempted", () => { + cy.visit("/submission/hasbeensubmitted"); + cy.findByText("Submitted!").should("not.exist"); + + cy.findByText("Submit").click(); + cy.findByText("Submitted!").should("exist"); + cy.findByText("First Name is a required field").should("exist"); + + cy.findByText("Reset").click(); + cy.findByText("Submitted!").should("not.exist"); + }); }); diff --git a/apps/test-app/cypress/integration/touched-state.ts b/apps/test-app/cypress/integration/touched-state.ts new file mode 100644 index 00000000..4bd0bc4c --- /dev/null +++ b/apps/test-app/cypress/integration/touched-state.ts @@ -0,0 +1,21 @@ +describe("Touched state", () => { + it("should track touched state and reset when the form is reset", () => { + cy.visit("/touched-state"); + + cy.findByText("firstName touched").should("not.exist"); + cy.findByText("lastName touched").should("not.exist"); + + cy.findByLabelText("First Name").focus().blur(); + cy.findByText("firstName touched").should("exist"); + cy.findByText("lastName touched").should("not.exist"); + + cy.findByLabelText("Last Name").focus().blur(); + cy.findByText("firstName touched").should("exist"); + cy.findByText("lastName touched").should("exist"); + + cy.findByText("Reset").click(); + + cy.findByText("firstName touched").should("not.exist"); + cy.findByText("lastName touched").should("not.exist"); + }); +}); diff --git a/apps/test-app/cypress/integration/validation-with-fetchers.ts b/apps/test-app/cypress/integration/validation-with-fetchers.ts index 5a11c1dc..ce957edf 100644 --- a/apps/test-app/cypress/integration/validation-with-fetchers.ts +++ b/apps/test-app/cypress/integration/validation-with-fetchers.ts @@ -15,10 +15,9 @@ describe("Validation with fetchers", () => { cy.findByLabelText("Email").focus().blur(); cy.findByText("Email is a required field").should("exist"); cy.findByLabelText("Email").type("not an email"); - cy.findByLabelText("Email").blur(); cy.findByText("Email must be a valid email").should("exist"); - cy.findByLabelText("Email").clear().type("an.email@example.com").blur(); + cy.findByLabelText("Email").clear().type("an.email@example.com"); cy.findByText("Email must be a valid email").should("not.exist"); cy.findByText("Email is a required field").should("not.exist"); @@ -44,7 +43,7 @@ describe("Validation with fetchers", () => { cy.findByLabelText("Last Name").type("Doe"); cy.findByText("Last Name is a required field").should("not.exist"); - cy.findByLabelText("Email").type("an.email@example.com").blur(); + cy.findByLabelText("Email").type("an.email@example.com"); cy.findByText("Email is a required field").should("not.exist"); cy.findByText("Submit").click(); diff --git a/apps/test-app/cypress/integration/validation.ts b/apps/test-app/cypress/integration/validation.ts index bdfc4312..dcdc05fa 100644 --- a/apps/test-app/cypress/integration/validation.ts +++ b/apps/test-app/cypress/integration/validation.ts @@ -15,7 +15,6 @@ describe("Validation", () => { cy.findByLabelText("Email").focus().blur(); cy.findByText("Email is a required field").should("exist"); cy.findByLabelText("Email").type("not an email"); - cy.findByLabelText("Email").blur(); cy.findByText("Email must be a valid email").should("exist"); cy.findByLabelText("Name of a contact").focus().blur(); @@ -23,7 +22,7 @@ describe("Validation", () => { cy.findByLabelText("Name of a contact").type("Someone else"); cy.findByText("Name of a contact is a required field").should("not.exist"); - cy.findByLabelText("Email").clear().type("an.email@example.com").blur(); + cy.findByLabelText("Email").clear().type("an.email@example.com"); cy.findByText("Email must be a valid email").should("not.exist"); cy.findByText("Email is a required field").should("not.exist"); @@ -58,7 +57,7 @@ describe("Validation", () => { cy.findByText("Submit").click(); cy.findByLabelText("Email").should("be.focused"); - cy.findByLabelText("Email").type("an.email@example.com").blur(); + cy.findByLabelText("Email").type("an.email@example.com"); cy.findByText("Email is a required field").should("not.exist"); cy.findByText("Submit").click(); @@ -104,7 +103,7 @@ describe("Validation", () => { cy.findByLabelText("First Name").type("John"); cy.findByLabelText("Last Name").type("Doe"); - cy.findByLabelText("Email").type("an.email@example.com").blur(); + cy.findByLabelText("Email").type("an.email@example.com"); cy.findByLabelText("Name of a contact").type("Someone else"); cy.findByText("Submit").click(); @@ -126,7 +125,7 @@ describe("Validation", () => { cy.findByLabelText("First Name").should("have.value", "John"); cy.findByLabelText("Last Name").should("have.value", "Doe"); - cy.findByLabelText("Email").type("an.email@example.com").blur(); + cy.findByLabelText("Email").type("an.email@example.com"); cy.findByLabelText("Name of a contact").type("Someone else"); cy.findByText("Submit").click(); diff --git a/packages/remix-validated-form/src/ValidatedForm.tsx b/packages/remix-validated-form/src/ValidatedForm.tsx index 2124bffe..02d4afc6 100644 --- a/packages/remix-validated-form/src/ValidatedForm.tsx +++ b/packages/remix-validated-form/src/ValidatedForm.tsx @@ -24,6 +24,7 @@ import { FieldErrors, Validator, FieldErrorsWithData, + TouchedFields, } from "./validation/types"; export type FormProps = { @@ -196,6 +197,8 @@ export function ValidatedForm({ const [fieldErrors, setFieldErrors] = useFieldErrors(fieldErrorsFromBackend); const isSubmitting = useIsSubmitting(action, subaction, fetcher); const defaultsToUse = useDefaultValues(fieldErrorsFromBackend, defaultValues); + const [touchedFields, setTouchedFields] = useState({}); + const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false); const formRef = useRef(null); useSubmitComplete(isSubmitting, () => { if (!fieldErrorsFromBackend && resetAfterSubmit) { @@ -211,6 +214,12 @@ export function ValidatedForm({ defaultValues: defaultsToUse, isSubmitting: isSubmitting ?? false, isValid: Object.keys(fieldErrors).length === 0, + touchedFields, + setFieldTouched: (fieldName: string, touched: boolean) => + setTouchedFields((prev) => ({ + ...prev, + [fieldName]: touched, + })), clearError: (fieldName) => { setFieldErrors((prev) => omit(prev, fieldName)); }, @@ -225,6 +234,8 @@ export function ValidatedForm({ ...prev, [fieldName]: error, })); + } else { + setFieldErrors((prev) => omit(prev, fieldName)); } }, registerReceiveFocus: (fieldName, handler) => { @@ -233,12 +244,15 @@ export function ValidatedForm({ customFocusHandlers().remove(fieldName, handler); }; }, + hasBeenSubmitted, }), [ fieldErrors, action, defaultsToUse, isSubmitting, + touchedFields, + hasBeenSubmitted, setFieldErrors, validator, customFocusHandlers, @@ -253,6 +267,7 @@ export function ValidatedForm({ {...rest} action={action} onSubmit={(event) => { + setHasBeenSubmitted(true); const result = validator.validate(getDataFromForm(event.currentTarget)); if (result.error) { event.preventDefault(); @@ -272,6 +287,8 @@ export function ValidatedForm({ onReset?.(event); if (event.defaultPrevented) return; setFieldErrors({}); + setTouchedFields({}); + setHasBeenSubmitted(false); }} > diff --git a/packages/remix-validated-form/src/hooks.ts b/packages/remix-validated-form/src/hooks.ts index 3f06ad04..a595f372 100644 --- a/packages/remix-validated-form/src/hooks.ts +++ b/packages/remix-validated-form/src/hooks.ts @@ -2,6 +2,11 @@ import get from "lodash/get"; import toPath from "lodash/toPath"; import { useContext, useEffect, useMemo } from "react"; import { FormContext } from "./internal/formContext"; +import { + createGetInputProps, + GetInputProps, + ValidationBehaviorOptions, +} from "./internal/getInputProps"; export type FieldProps = { /** @@ -20,6 +25,18 @@ export type FieldProps = { * The default value of the field, if there is one. */ defaultValue?: any; + /** + * Whether or not the field has been touched. + */ + touched: boolean; + /** + * Helper to set the touched state of the field. + */ + setTouched: (touched: boolean) => void; + /** + * Helper to get all the props necessary for a regular input. + */ + getInputProps: GetInputProps; }; /** @@ -34,6 +51,10 @@ export const useField = ( * This is useful for custom components that use a hidden input. */ handleReceiveFocus?: () => void; + /** + * Allows you to specify when a field gets validated (when using getInputProps) + */ + validationBehavior?: Partial; } ): FieldProps => { const { @@ -42,8 +63,12 @@ export const useField = ( validateField, defaultValues, registerReceiveFocus, + touchedFields, + setFieldTouched, + hasBeenSubmitted, } = useContext(FormContext); + const isTouched = !!touchedFields[name]; const { handleReceiveFocus } = options ?? {}; useEffect(() => { @@ -51,8 +76,8 @@ export const useField = ( return registerReceiveFocus(name, handleReceiveFocus); }, [handleReceiveFocus, name, registerReceiveFocus]); - const field = useMemo( - () => ({ + const field = useMemo(() => { + const helpers = { error: fieldErrors[name], clearError: () => { clearError(name); @@ -61,9 +86,30 @@ export const useField = ( defaultValue: defaultValues ? get(defaultValues, toPath(name), undefined) : undefined, - }), - [clearError, defaultValues, fieldErrors, name, validateField] - ); + touched: isTouched, + setTouched: (touched: boolean) => setFieldTouched(name, touched), + }; + const getInputProps = createGetInputProps({ + ...helpers, + name, + hasBeenSubmitted, + validationBehavior: options?.validationBehavior, + }); + return { + ...helpers, + getInputProps, + }; + }, [ + fieldErrors, + name, + defaultValues, + isTouched, + hasBeenSubmitted, + options?.validationBehavior, + clearError, + validateField, + setFieldTouched, + ]); return field; }; diff --git a/packages/remix-validated-form/src/internal/formContext.ts b/packages/remix-validated-form/src/internal/formContext.ts index 912b0b69..84f96d19 100644 --- a/packages/remix-validated-form/src/internal/formContext.ts +++ b/packages/remix-validated-form/src/internal/formContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import { FieldErrors } from "../validation/types"; +import { FieldErrors, TouchedFields } from "../validation/types"; export type FormContextValue = { /** @@ -22,6 +22,12 @@ export type FormContextValue = { * Whether or not the form is submitting. */ isSubmitting: boolean; + /** + * Whether or not a submission has been attempted. + * This is true once the form has been submitted, even if there were validation errors. + * Resets to false when the form is reset. + */ + hasBeenSubmitted: boolean; /** * Whether or not the form is valid. * This is a shortcut for `Object.keys(fieldErrors).length === 0`. @@ -36,6 +42,14 @@ export type FormContextValue = { * the field needs to receive focus due to a validation error. */ registerReceiveFocus: (fieldName: string, handler: () => void) => () => void; + /** + * Any fields that have been touched by the user. + */ + touchedFields: TouchedFields; + /** + * Change the touched state of the specified field. + */ + setFieldTouched: (fieldName: string, touched: boolean) => void; }; export const FormContext = createContext({ @@ -43,6 +57,9 @@ export const FormContext = createContext({ clearError: () => {}, validateField: () => {}, isSubmitting: false, + hasBeenSubmitted: false, isValid: true, registerReceiveFocus: () => () => {}, + touchedFields: {}, + setFieldTouched: () => {}, }); diff --git a/packages/remix-validated-form/src/internal/getInputProps.ts b/packages/remix-validated-form/src/internal/getInputProps.ts new file mode 100644 index 00000000..2122a8f5 --- /dev/null +++ b/packages/remix-validated-form/src/internal/getInputProps.ts @@ -0,0 +1,79 @@ +export type ValidationBehavior = "onBlur" | "onChange" | "onSubmit"; + +export type ValidationBehaviorOptions = { + initial: ValidationBehavior; + whenTouched: ValidationBehavior; + whenSubmitted: ValidationBehavior; +}; + +export type CreateGetInputPropsOptions = { + clearError: () => void; + validate: () => void; + defaultValue?: any; + touched: boolean; + setTouched: (touched: boolean) => void; + hasBeenSubmitted: boolean; + validationBehavior?: Partial; + name: string; +}; + +export type MinimalInputProps = { + onChange?: (...args: any[]) => void; + onBlur?: (...args: any[]) => void; +}; + +export type MinimalResult = { + name: string; + onChange: (...args: any[]) => void; + onBlur: (...args: any[]) => void; + defaultValue?: any; +}; + +export type GetInputProps = ( + props?: T & MinimalInputProps +) => T & MinimalResult; + +const defaultValidationBehavior: ValidationBehaviorOptions = { + initial: "onBlur", + whenTouched: "onChange", + whenSubmitted: "onChange", +}; + +export const createGetInputProps = ({ + clearError, + validate, + defaultValue, + touched, + setTouched, + hasBeenSubmitted, + validationBehavior, + name, +}: CreateGetInputPropsOptions): GetInputProps => { + const validationBehaviors = { + ...defaultValidationBehavior, + ...validationBehavior, + }; + + return (props = {} as any) => { + const behavior = hasBeenSubmitted + ? validationBehaviors.whenSubmitted + : touched + ? validationBehaviors.whenTouched + : validationBehaviors.initial; + return { + ...props, + onChange: (...args) => { + if (behavior === "onChange") validate(); + else clearError(); + return props?.onChange?.(...args); + }, + onBlur: (...args) => { + if (behavior === "onBlur") validate(); + setTouched(true); + return props?.onBlur?.(...args); + }, + defaultValue, + name, + }; + }; +}; diff --git a/packages/remix-validated-form/src/validation/types.ts b/packages/remix-validated-form/src/validation/types.ts index a0ee3939..cc2c60f2 100644 --- a/packages/remix-validated-form/src/validation/types.ts +++ b/packages/remix-validated-form/src/validation/types.ts @@ -1,5 +1,7 @@ export type FieldErrors = Record; +export type TouchedFields = Record; + export type FieldErrorsWithData = FieldErrors & { _submittedData: any }; export type GenericObject = { [key: string]: any }; diff --git a/packages/validation-tests/tests/inputProps.test.ts b/packages/validation-tests/tests/inputProps.test.ts new file mode 100644 index 00000000..820635ac --- /dev/null +++ b/packages/validation-tests/tests/inputProps.test.ts @@ -0,0 +1,250 @@ +import { + createGetInputProps, + CreateGetInputPropsOptions, +} from "remix-validated-form/src/internal/getInputProps"; + +const fakeEvent = { fake: "event" } as any; + +describe("getInputProps", () => { + describe("initial", () => { + it("should validate on blur by default", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: false, + hasBeenSubmitted: false, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).not.toBeCalled(); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + + it("should respect provided validation behavior", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: false, + hasBeenSubmitted: false, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + validationBehavior: { + initial: "onChange", + }, + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).toBeCalledTimes(1); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + + it("should not validate when behavior is onSubmit", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: false, + hasBeenSubmitted: false, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + validationBehavior: { + initial: "onSubmit", + }, + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).not.toBeCalled(); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).not.toBeCalled(); + }); + }); + + describe("whenTouched", () => { + it("should validate on change by default", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: true, + hasBeenSubmitted: false, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).toBeCalledTimes(1); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + + it("should respect provided validation behavior", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: true, + hasBeenSubmitted: false, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + validationBehavior: { + whenTouched: "onBlur", + }, + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).not.toBeCalled(); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + }); + + describe("whenSubmitted", () => { + it("should validate on change by default", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: true, + hasBeenSubmitted: true, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).toBeCalledTimes(1); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + + it("should respect provided validation behavior", () => { + const options: CreateGetInputPropsOptions = { + name: "some-field", + defaultValue: "test default value", + touched: true, + hasBeenSubmitted: true, + setTouched: jest.fn(), + clearError: jest.fn(), + validate: jest.fn(), + validationBehavior: { + whenSubmitted: "onBlur", + }, + }; + const getInputProps = createGetInputProps(options); + + const provided = { + onBlur: jest.fn(), + onChange: jest.fn(), + }; + const { onChange, onBlur } = getInputProps(provided); + + onChange!(fakeEvent); + expect(provided.onChange).toBeCalledTimes(1); + expect(provided.onChange).toBeCalledWith(fakeEvent); + expect(options.setTouched).not.toBeCalled(); + expect(options.validate).not.toBeCalled(); + + onBlur!(fakeEvent); + expect(provided.onBlur).toBeCalledTimes(1); + expect(provided.onBlur).toBeCalledWith(fakeEvent); + expect(options.setTouched).toBeCalledTimes(1); + expect(options.setTouched).toBeCalledWith(true); + expect(options.validate).toBeCalledTimes(1); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index a583745f..bfefa4c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6721,6 +6721,11 @@ ospath@^1.2.2: resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= +outdent@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/outdent/-/outdent-0.8.0.tgz#2ebc3e77bf49912543f1008100ff8e7f44428eb0" + integrity sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A== + p-cancelable@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"