diff --git a/.eslintrc b/.eslintrc index 301bbb0..1c75c37 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,6 +14,7 @@ "plugins": ["@typescript-eslint", "react", "prettier"], "rules": { "@typescript-eslint/no-unnecessary-type-constraint": "off", - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "no-console": "error" } } diff --git a/README.md b/README.md index 586d5f6..d03b229 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ export const action = async ({ request }: ActionArgs) => { ### createFormData -createFormData is a utility function that can be used to create a FormData object from the data returned by the handleSubmit function from `react-hook-form`. It takes two arguments, first one is the `data` from the `handleSubmit` function and the second one is the key that the data will be stored in the FormData object. (default is `formData`). It returns a FormData object. +createFormData is a utility function that can be used to create a FormData object from the data returned by the handleSubmit function from `react-hook-form`. It takes one argument, the `data` from the `handleSubmit` function and it converts everything it can to strings and appends files as well. It returns a FormData object. ```jsx /** all the same code from above */ @@ -168,10 +168,8 @@ export default function MyForm() { ..., submitHandlers: { onValid: data => { - // This will create a FormData instance ready to be sent to the server, by default all your data is stored inside a key called `formData` but this behavior can be changed by passing a second argument to the function - const formData = createFormData(data); - // Example with a custom key - const formDataCustom = createFormData(data, "yourkeyhere"); + // This will create a FormData instance ready to be sent to the server, by default all your data is converted to a string before sent + const formData = createFormData(data); // Do something with the formData } } @@ -186,7 +184,7 @@ export default function MyForm() { ### parseFormData -parseFormData is a utility function that can be used to parse the data submitted to the action by the handleSubmit function from `react-hook-form`. It takes two arguments, first one is the `request` submitted from the frontend and the second one is the key that the data will be stored in the FormData object. (default is `formData`). It returns an object that contains unvalidated `data` submitted from the frontend. +parseFormData is a utility function that can be used to parse the data submitted to the action by the handleSubmit function from `react-hook-form`. It takes two arguments, first one is the `request` submitted from the frontend and the second one is `preserveStringified`, the form data you submit will be cast to strings because that is how form data works, when retrieving it you can either keep everything as strings or let the helper try to parse it back to original types (eg number to string), default is `false`. It returns an object that contains unvalidated `data` submitted from the frontend. ```jsx @@ -195,8 +193,9 @@ parseFormData is a utility function that can be used to parse the data submitted export const action = async ({ request }: ActionArgs) => { // Allows you to get the data from the request object without having to validate it const formData = await parseFormData(request); - // If you used a custom key (eg. `yourkeyhere` from above you can extract like this) - const formDataCustom = await parseFormData(request, "yourkeyhere"); + // formData.age will be a number + const formDataStringified = await parseFormData(request, true); + // formDataStringified.age will be a string // Do something with the data }; diff --git a/package.json b/package.json index 7711fa6..dcb1e9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "remix-hook-form", - "version": "2.0.3", + "version": "3.0.0", "description": "Utility wrapper around react-hook-form for use with Remix.run", "type": "module", "main": "./dist/index.cjs", @@ -32,7 +32,7 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", "remix-dev": "npm run dev -w src/testing-app", - "build:dev": "npm run build", + "build:dev": "tsup src/index.ts --format cjs,esm --dts", "build:dev:watch": "npm run build -- --watch", "dev": "npm-run-all -s build:dev -p remix-dev build:dev:watch", "vite": "npm run build --watch -m development", @@ -97,4 +97,4 @@ "vitest": "^0.30.1", "zod": "^3.21.4" } -} +} \ No newline at end of file diff --git a/src/hook/index.test.tsx b/src/hook/index.test.tsx index 70c10b6..a29e2e6 100644 --- a/src/hook/index.test.tsx +++ b/src/hook/index.test.tsx @@ -68,6 +68,25 @@ describe("useRemixForm", () => { }); }); + it("should reset isSubmitSuccessful after submission if reset is called", async () => { + const { result } = renderHook(() => + useRemixForm({ + resolver: () => ({ values: {}, errors: {} }), + }), + ); + + act(() => { + result.current.handleSubmit(); + }); + await waitFor(() => { + expect(result.current.formState.isSubmitSuccessful).toBe(true); + }); + act(() => { + result.current.reset(); + }); + expect(result.current.formState.isSubmitSuccessful).toBe(false); + }); + it("should submit the form data to the server when the form is valid", async () => { const { result } = renderHook(() => useRemixForm({ diff --git a/src/hook/index.tsx b/src/hook/index.tsx index 6d0e12a..2d2f7cb 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -13,6 +13,7 @@ import { } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form"; import type { + DeepPartial, FieldValues, Path, RegisterOptions, @@ -42,6 +43,8 @@ export const useRemixForm = ({ fetcher, ...formProps }: UseRemixFormOptions) => { + const [isSubmittedSuccessfully, setIsSubmittedSuccessfully] = + React.useState(false); const actionSubmit = useSubmit(); const actionData = useActionData(); const submit = fetcher?.submit ?? actionSubmit; @@ -54,7 +57,9 @@ export const useRemixForm = ({ // Submits the data to the server when form is valid const onSubmit = (data: T) => { - submit(createFormData({ ...data, ...submitData }), { + setIsSubmittedSuccessfully(true); + const formData = createFormData({ ...data, ...submitData }); + submit(formData, { method: "post", ...submitConfig, }); @@ -92,6 +97,10 @@ export const useRemixForm = ({ submitHandlers?.onValid ?? onSubmit, submitHandlers?.onInvalid ?? onInvalid, ), + reset: (values?: T | DeepPartial | undefined) => { + setIsSubmittedSuccessfully(false); + methods.reset(values); + }, register: ( name: Path, options?: RegisterOptions & { @@ -106,7 +115,7 @@ export const useRemixForm = ({ formState: { dirtyFields, isDirty, - isSubmitSuccessful, + isSubmitSuccessful: isSubmittedSuccessfully || isSubmitSuccessful, isSubmitted, isSubmitting: isSubmittingForm || isSubmitting, isValid, diff --git a/src/testing-app/app/routes/_index.tsx b/src/testing-app/app/routes/_index.tsx index 3d140cf..af61592 100644 --- a/src/testing-app/app/routes/_index.tsx +++ b/src/testing-app/app/routes/_index.tsx @@ -1,11 +1,46 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { json, type ActionFunctionArgs } from "@remix-run/node"; -import { useFetcher } from "@remix-run/react"; -import { getValidatedFormData, useRemixForm } from "remix-hook-form"; +import { + json, + type ActionFunctionArgs, + unstable_parseMultipartFormData, + LoaderFunctionArgs, +} from "@remix-run/node"; +import { Form, useFetcher } from "@remix-run/react"; +import { + getValidatedFormData, + useRemixForm, + parseFormData, + getFormDataFromSearchParams, +} from "remix-hook-form"; import { z } from "zod"; - +const MAX_FILE_SIZE = 500000; +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]; const schema = z.object({ - content: z.string().nonempty("content is required"), + array: z.array(z.string()).nonempty(), + user: z.object({ + name: z.string().nonempty(), + email: z.string().email(), + }), + boolean: z.boolean(), + number: z.number().min(5), + + // file: z + // .any() + // .refine((files) => files?.length == 1, "Image is required.") + // .refine( + // (files) => files?.[0]?.size <= MAX_FILE_SIZE, + // `Max file size is 5MB.`, + // ) + // .refine( + // (files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type), + // ".jpg, .jpeg, .png and .webp files are accepted.", + // ) + // .nullish(), }); type FormData = z.infer; @@ -13,7 +48,8 @@ type FormData = z.infer; const resolver = zodResolver(schema); export const action = async ({ request }: ActionFunctionArgs) => { - const { data } = await getValidatedFormData(request, resolver); + const data = await getValidatedFormData(request, resolver); + console.log("action", data); await new Promise((resolve) => setTimeout(resolve, 2000)); return null; // Make DB call or similar here. @@ -24,41 +60,50 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; }; -export const loader = () => { +export const loader = ({ request }: LoaderFunctionArgs) => { + const data = getFormDataFromSearchParams(request); + console.log("loader", data); return json({ result: "success" }); }; export default function Index() { - const fetcher = useFetcher(); - const { register, handleSubmit, formState } = useRemixForm({ - resolver, - fetcher, - submitConfig: { - method: "GET", - }, - }); + const { register, handleSubmit, formState, watch, reset } = + useRemixForm({ + resolver, + defaultValues: { + array: ["a", "b"], + boolean: true, + number: 6, + user: { + name: "John Doe", + email: "test@test.com", + }, + }, + submitConfig: { + method: "POST", + }, + }); return (

Add a thing...

-

Current Errors: {JSON.stringify(formState.errors)}

- -
- + +
+
+ + + + + + +
- +
); } diff --git a/src/testing-app/remix.config.js b/src/testing-app/remix.config.js index 916ee5f..977e37a 100644 --- a/src/testing-app/remix.config.js +++ b/src/testing-app/remix.config.js @@ -7,4 +7,5 @@ module.exports = { // publicPath: "/build/", serverModuleFormat: "cjs", watchPaths: ["../../dist"], + tailwind: true, }; diff --git a/src/utilities/index.test.ts b/src/utilities/index.test.ts index 77feae7..a1ccad0 100644 --- a/src/utilities/index.test.ts +++ b/src/utilities/index.test.ts @@ -1,4 +1,4 @@ -import { array, object, string } from "zod"; +import { array, boolean, number, object, string } from "zod"; import { createFormData, generateFormData, @@ -17,29 +17,30 @@ describe("createFormData", () => { const data = { name: "John Doe", age: 30, + bool: true, + object: { + test: "1", + number: 2, + }, + array: ["1", "2", "3"], }; const formData = createFormData(data); - expect(formData.get("formData")).toEqual(JSON.stringify(data)); - }); - it("should create a FormData object with the provided key and data", () => { - const data = { - name: "Jane Doe", - age: 25, - }; - const key = "myData"; - const formData = createFormData(data, key); - expect(formData.get(key)).toEqual(JSON.stringify(data)); - }); - - it("should handle empty data", () => { - const formData = createFormData({}); - expect(formData.get("formData")).toEqual("{}"); + expect(formData.get("name")).toEqual(data.name); + expect(formData.get("age")).toEqual(data.age.toString()); + expect(formData.get("object")).toEqual(JSON.stringify(data.object)); + expect(formData.get("array")).toEqual(JSON.stringify(data.array)); + expect(formData.get("bool")).toEqual(data.bool.toString()); }); it("should handle null data", () => { const formData = createFormData(null as any); - expect(formData.get("formData")).toEqual("null"); + expect(formData).toBeTruthy(); + }); + + it("should handle undefined data", () => { + const formData = createFormData(undefined as any); + expect(formData).toBeTruthy(); }); }); @@ -91,7 +92,7 @@ describe("parseFormData", () => { const parsedData = await parseFormData(request); expect(parsedData).toEqual({ name: "John Doe", - age: "30", + age: 30, hobbies: ["Reading", "Writing", "Coding"], other: { skills: ["testing", "testing"], @@ -100,16 +101,15 @@ describe("parseFormData", () => { }); }); - it("should throw an error if the retrieved data is not a string (but a file instead)", async () => { + it("should not throw an error when a file is passed in", async () => { const request = new Request("http://localhost:3000"); const requestFormDataSpy = vi.spyOn(request, "formData"); const blob = new Blob(["Hello, world!"], { type: "text/plain" }); const mockFormData = new FormData(); mockFormData.append("formData", blob); requestFormDataSpy.mockResolvedValueOnce(mockFormData); - await expect(parseFormData(request)).rejects.toThrowError( - "Data is not a string" - ); + const data = await parseFormData<{ formData: any }>(request); + expect(data.formData).toBeTypeOf("string"); }); }); @@ -283,7 +283,7 @@ describe("getFormDataFromSearchParams", () => { expect(formData).toStrictEqual({ colors: ["red", "green", "blue"], - numbers: ["1", "2", "3"], + numbers: [1, 2, 3], }); }); }); @@ -297,7 +297,7 @@ describe("validateFormData", () => { const returnData = await validateFormData( formData, - zodResolver(object({ name: string(), email: string().email() })) + zodResolver(object({ name: string(), email: string().email() })), ); expect(returnData.errors).toStrictEqual(undefined); expect(returnData.data).toStrictEqual(formData); @@ -310,7 +310,7 @@ describe("validateFormData", () => { }; const returnData = await validateFormData( formData, - zodResolver(object({ name: string(), email: string().email() })) + zodResolver(object({ name: string(), email: string().email() })), ); expect(returnData.errors).toStrictEqual({ email: { @@ -334,11 +334,11 @@ describe("getValidatedFormData", () => { name: string(), }), colors: array(string()), - numbers: array(string()), + numbers: array(number()), }); const formData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(formData).toStrictEqual({ data: { @@ -346,14 +346,14 @@ describe("getValidatedFormData", () => { name: "john", }, colors: ["red", "green", "blue"], - numbers: ["1", "2", "3"], + numbers: [1, 2, 3], }, receivedValues: { user: { name: "john", }, colors: ["red", "green", "blue"], - numbers: ["1", "2", "3"], + numbers: [1, 2, 3], }, errors: undefined, }); @@ -362,22 +362,25 @@ describe("getValidatedFormData", () => { it("gets valid form data from a POST request when it is js", async () => { const formData = { name: "John Doe", - age: "30", + age: 30, hobbies: ["Reading", "Writing", "Coding"], + boolean: true, + numbers: [1, 2, 3], other: { skills: ["testing", "testing"], something: "else", }, }; - const request = new Request("http://localhost:3000"); + const request = new Request("http://localhost:3000", { method: "POST" }); const requestFormDataSpy = vi.spyOn(request, "formData"); - const data = new FormData(); - data.append("formData", JSON.stringify(formData)); + const data = createFormData(formData); requestFormDataSpy.mockResolvedValueOnce(data); const schema = object({ name: string(), - age: string(), + age: number(), + boolean: boolean(), + numbers: array(number()), hobbies: array(string()), other: object({ skills: array(string()), @@ -386,7 +389,7 @@ describe("getValidatedFormData", () => { }); const validatedFormData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(validatedFormData).toStrictEqual({ data: formData, @@ -405,13 +408,13 @@ describe("getValidatedFormData", () => { formData.append("other.skills.0", "testing"); formData.append("other.skills.1", "testing"); formData.append("other.something", "else"); - const request = new Request("http://localhost:3000"); + const request = new Request("http://localhost:3000", { method: "POST" }); const requestFormDataSpy = vi.spyOn(request, "formData"); requestFormDataSpy.mockResolvedValueOnce(formData); const schema = object({ name: string(), - age: string(), + age: number(), hobbies: array(string()), other: object({ skills: array(string()), @@ -420,12 +423,12 @@ describe("getValidatedFormData", () => { }); const returnData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(returnData).toStrictEqual({ data: { name: "John Doe", - age: "30", + age: 30, hobbies: ["Reading", "Writing", "Coding"], other: { skills: ["testing", "testing"], @@ -434,7 +437,7 @@ describe("getValidatedFormData", () => { }, receivedValues: { name: "John Doe", - age: "30", + age: 30, hobbies: ["Reading", "Writing", "Coding"], other: { skills: ["testing", "testing"], diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 3e895f1..7105c2f 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,4 +1,4 @@ -import { +import type { FieldValues, Resolver, FieldErrors, @@ -6,19 +6,34 @@ import { DeepRequired, } from "react-hook-form"; +const tryParseJSON = (jsonString: string) => { + try { + const json = JSON.parse(jsonString); + return json; + } catch (e) { + return jsonString; + } +}; + /** * Generates an output object from the given form data, where the keys in the output object retain * the structure of the keys in the form data. Keys containing integer indexes are treated as arrays. * * @param {FormData} formData - The form data to generate an output object from. + * @param {boolean} [preserveStringified=false] - Whether to preserve stringified values or try to convert them * @returns {Object} The output object generated from the form data. */ -export const generateFormData = (formData: FormData) => { +export const generateFormData = ( + formData: FormData, + preserveStringified = false, +) => { // Initialize an empty output object. const outputObject: Record = {}; // Iterate through each key-value pair in the form data. for (const [key, value] of formData.entries()) { + // Try to convert data to the original type, otherwise return the original value + const data = preserveStringified ? value : tryParseJSON(value.toString()); // Split the key into an array of parts. const keyParts = key.split("."); // Initialize a variable to point to the current object in the output object. @@ -47,18 +62,19 @@ export const generateFormData = (formData: FormData) => { if (!currentObject[key]) { currentObject[key] = []; } - currentObject[key].push(value); + + currentObject[key].push(data); } // Handles array.foo.0 cases if (!lastKeyPartIsArray) { // If the last key part is a valid integer index, push the value to the current array. if (/^\d+$/.test(lastKeyPart)) { - currentObject.push(value); + currentObject.push(data); } // Otherwise, set a property on the current object with the last key part and the corresponding value. else { - currentObject[lastKeyPart] = value; + currentObject[lastKeyPart] = data; } } } @@ -67,9 +83,12 @@ export const generateFormData = (formData: FormData) => { return outputObject; }; -export const getFormDataFromSearchParams = (request: Pick) => { +export const getFormDataFromSearchParams = ( + request: Pick, + preserveStringified = false, +) => { const searchParams = new URL(request.url).searchParams; - return generateFormData(searchParams as any); + return generateFormData(searchParams as any, preserveStringified); }; export const isGet = (request: Pick) => @@ -86,11 +105,12 @@ export const isGet = (request: Pick) => */ export const getValidatedFormData = async ( request: Request, - resolver: Resolver + resolver: Resolver, ) => { const data = isGet(request) ? getFormDataFromSearchParams(request) : await parseFormData(request); + const validatedOutput = await validateFormData(data, resolver); return { ...validatedOutput, receivedValues: data }; }; @@ -103,12 +123,12 @@ export const getValidatedFormData = async ( */ export const validateFormData = async ( data: any, - resolver: Resolver + resolver: Resolver, ) => { const { errors, values } = await resolver( data, {}, - { shouldUseNativeValidation: false, fields: {} } + { shouldUseNativeValidation: false, fields: {} }, ); if (Object.keys(errors).length > 0) { @@ -124,42 +144,46 @@ export const validateFormData = async ( @param {string} [key="formData"] - The key to be used for adding the data to the FormData. @returns {FormData} - The FormData object with the data added to it. */ -export const createFormData = ( - data: T, - key = "formData" -): FormData => { +export const createFormData = (data: T): FormData => { const formData = new FormData(); - const finalData = JSON.stringify(data); - formData.append(key, finalData); + if (!data) { + return formData; + } + Object.entries(data).map(([key, value]) => { + if (Array.isArray(value)) { + formData.append(key, JSON.stringify(value)); + } else if (value instanceof File) { + formData.append(key, value); + } else if (typeof value === "object" && value !== null) { + formData.append(key, JSON.stringify(value)); + } else if (typeof value === "boolean") { + formData.append(key, value.toString()); + } else if (typeof value === "number") { + formData.append(key, value.toString()); + } else { + formData.append(key, value); + } + }); + return formData; }; + /** Parses the specified Request object's FormData to retrieve the data associated with the specified key. @template T - The type of the data to be returned. @param {Request} request - The Request object whose FormData is to be parsed. -@param {string} [key="formData"] - The key of the data to be retrieved from the FormData. +@param {boolean} [preserveStringified=false] - Whether to preserve stringified values or try to convert them @returns {Promise} - A promise that resolves to the data of type T. @throws {Error} - If no data is found for the specified key, or if the retrieved data is not a string. */ export const parseFormData = async ( request: Request, - key = "formData" + preserveStringified = false, ): Promise => { const formData = await request.formData(); - const data = formData.get(key); - - if (!data) { - return generateFormData(formData); - } - - if (!(typeof data === "string")) { - throw new Error("Data is not a string"); - } - - return JSON.parse(data); + return generateFormData(formData, preserveStringified); }; /** - Merges two error objects generated by a resolver, where T is the generic type of the object. The function recursively merges the objects and returns the resulting object. @template T - The generic type of the object. @@ -171,7 +195,7 @@ export const mergeErrors = ( frontendErrors: Partial>>, backendErrors?: Partial>>, validKeys: string[] = [], - depth = 0 + depth = 0, ) => { if (!backendErrors) { return frontendErrors; @@ -179,7 +203,7 @@ export const mergeErrors = ( for (const [key, rightValue] of Object.entries(backendErrors) as [ keyof T, - DeepRequired[keyof T] + DeepRequired[keyof T], ][]) { if ( !validKeys.includes(key.toString()) &&