From 8073d07991c6024547c68e31f42273e7ec7a1ff7 Mon Sep 17 00:00:00 2001 From: Toa Games Date: Sun, 8 Oct 2023 12:38:37 -0400 Subject: [PATCH 1/3] fixes bug with isValid & isSubmitted states --- src/hook/index.test.tsx | 39 ++++++++++++++++++++++++++++++++++++++- src/hook/index.tsx | 6 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/hook/index.test.tsx b/src/hook/index.test.tsx index 70c10b6..ebee838 100644 --- a/src/hook/index.test.tsx +++ b/src/hook/index.test.tsx @@ -36,7 +36,7 @@ describe("useRemixForm", () => { isSubmitSuccessful: false, isSubmitted: false, isSubmitting: false, - isValid: false, + isValid: true, isValidating: false, touchedFields: {}, submitCount: 0, @@ -68,6 +68,43 @@ describe("useRemixForm", () => { }); }); + it("should call onInvalid function when the form is invalid and isValid should be false", async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const errors = { name: { message: "Name is required" } }; + const values = { name: "" }; + + const { result } = renderHook(() => + useRemixForm({ + resolver: () => ({ values, errors }), + submitHandlers: { + onValid, + onInvalid, + }, + }), + ); + + act(() => { + result.current.handleSubmit(); + }); + + await waitFor(() => { + expect(result.current.formState).toEqual({ + dirtyFields: {}, + isDirty: false, + isSubmitSuccessful: false, + isSubmitted: false, + isSubmitting: false, + isValid: false, + isValidating: false, + touchedFields: {}, + submitCount: 1, + isLoading: false, + errors, + }); + }); + }); + 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..3adacaa 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -70,9 +70,7 @@ export const useRemixForm = ({ dirtyFields, isDirty, isSubmitSuccessful, - isSubmitted, isSubmitting, - isValid, isValidating, touchedFields, submitCount, @@ -86,6 +84,10 @@ export const useRemixForm = ({ validKeys, ); + const isValid = !(Object.keys(errors).length > 0); + const isSubmitted = + data && Object.keys(data).length > 0 && isValid ? true : false; + return { ...methods, handleSubmit: methods.handleSubmit( From 0134b2e537eb1a302c646751065431a6e699e7ce Mon Sep 17 00:00:00 2001 From: Toa Games Date: Sun, 8 Oct 2023 12:39:35 -0400 Subject: [PATCH 2/3] fixes bug with isValid & isSubmitted states --- src/hook/index.test.tsx | 39 ++++++++++++++++++++++++++++++++++++++- src/hook/index.tsx | 6 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/hook/index.test.tsx b/src/hook/index.test.tsx index 70c10b6..ebee838 100644 --- a/src/hook/index.test.tsx +++ b/src/hook/index.test.tsx @@ -36,7 +36,7 @@ describe("useRemixForm", () => { isSubmitSuccessful: false, isSubmitted: false, isSubmitting: false, - isValid: false, + isValid: true, isValidating: false, touchedFields: {}, submitCount: 0, @@ -68,6 +68,43 @@ describe("useRemixForm", () => { }); }); + it("should call onInvalid function when the form is invalid and isValid should be false", async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const errors = { name: { message: "Name is required" } }; + const values = { name: "" }; + + const { result } = renderHook(() => + useRemixForm({ + resolver: () => ({ values, errors }), + submitHandlers: { + onValid, + onInvalid, + }, + }), + ); + + act(() => { + result.current.handleSubmit(); + }); + + await waitFor(() => { + expect(result.current.formState).toEqual({ + dirtyFields: {}, + isDirty: false, + isSubmitSuccessful: false, + isSubmitted: false, + isSubmitting: false, + isValid: false, + isValidating: false, + touchedFields: {}, + submitCount: 1, + isLoading: false, + errors, + }); + }); + }); + 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..3adacaa 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -70,9 +70,7 @@ export const useRemixForm = ({ dirtyFields, isDirty, isSubmitSuccessful, - isSubmitted, isSubmitting, - isValid, isValidating, touchedFields, submitCount, @@ -86,6 +84,10 @@ export const useRemixForm = ({ validKeys, ); + const isValid = !(Object.keys(errors).length > 0); + const isSubmitted = + data && Object.keys(data).length > 0 && isValid ? true : false; + return { ...methods, handleSubmit: methods.handleSubmit( From 259ecd6f9e8e89978e615ebd6cdb218df98b89c1 Mon Sep 17 00:00:00 2001 From: Toa Games Date: Mon, 9 Oct 2023 19:05:43 -0400 Subject: [PATCH 3/3] add react-hook-form default behavior and refactored mergeErrors --- README.md | 26 +++-- src/hook/index.tsx | 75 +++++++++++-- src/utilities/index.test.ts | 217 +++++++++++++++++++++++++++++++++--- src/utilities/index.ts | 121 +++++++++++++++----- 4 files changed, 380 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 586d5f6..758d057 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@ ![GitHub Repo stars](https://img.shields.io/github/stars/Code-Forge-Net/remix-hook-form?style=social) ![npm](https://img.shields.io/npm/v/remix-hook-form?style=plastic) ![GitHub](https://img.shields.io/github/license/Code-Forge-Net/remix-hook-form?style=plastic) -![npm](https://img.shields.io/npm/dy/remix-hook-form?style=plastic) -![GitHub top language](https://img.shields.io/github/languages/top/Code-Forge-Net/remix-hook-form?style=plastic) +![npm](https://img.shields.io/npm/dy/remix-hook-form?style=plastic) +![GitHub top language](https://img.shields.io/github/languages/top/Code-Forge-Net/remix-hook-form?style=plastic) Remix-hook-form is a powerful and lightweight wrapper around [react-hook-form](https://react-hook-form.com/) that streamlines the process of working with forms and form data in your [Remix](https://remix.run) applications. With a comprehensive set of hooks and utilities, you'll be able to easily leverage the flexibility of react-hook-form without the headache of boilerplate code. -And the best part? Remix-hook-form has zero dependencies, making it easy to integrate into your existing projects and workflows. Say goodbye to bloated dependencies and hello to a cleaner, more efficient development process with Remix-hook-form. +And the best part? Remix-hook-form has zero dependencies, making it easy to integrate into your existing projects and workflows. Say goodbye to bloated dependencies and hello to a cleaner, more efficient development process with Remix-hook-form. Oh, and did we mention that this is fully Progressively enhanced? That's right, you can use this with or without javascript! @@ -99,7 +99,7 @@ If the form is submitted without js it will try to parse the formData object and getValidatedFormData is a utility function that can be used to validate form data in your action. It takes two arguments: the request object and the resolver function. It returns an object with three properties: `errors`, `receivedValues` and `data`. If there are no errors, `errors` will be `undefined`. If there are errors, `errors` will be an object with the same shape as the `errors` object returned by `useRemixForm`. If there are no errors, `data` will be an object with the same shape as the `data` object returned by `useRemixForm`. The `receivedValues` property allows you to set the default values of your form to the values that were received from the request object. This is useful if you want to display the form again with the values that were submitted by the user when there is no JS present - + #### Example with errors only If you don't want the form to persist submitted values in the case of validation errors then you can just return the `errors` object directly from the action. ```jsx @@ -212,6 +212,16 @@ If you're using a GET request formData is not available on the request so you ca `useRemixForm` is a hook that can be used to create a form in your Remix application. It's basically the same as react-hook-form's [`useForm`](https://www.react-hook-form.com/api/useform/) hook, with the following differences: +### usePrevious + +`usePrevious` is a common custom hook that takes in a data object and return the previous state of that data. This is a simple version of it that is pretty efficient. It will return undefined on the first render as it never had a previous value. + +### useIsNewData + +`useIsNewData` is a custom hook that takes in a data object and as long as the data in different it will return the new value. Its used in this package to prevent remix's default behavior of useActionData returning the same value on every re-render. Thus, needing to alway submit your form on every state change. + +If you would rather have remix default behavior just set `shouldResetActionData = true` on `useRemixForm` function call. + **Additional options** - `submitHandlers`: an object containing two properties: - `onValid`: can be passed into the function to override the default behavior of the `handleSubmit` success case provided by the hook. @@ -242,7 +252,7 @@ The `errors` object inside `formState` is automatically populated with the error const { ... } = useRemixForm({ ...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM, submitHandlers: { - onValid: data => { + onValid: data => { // Do something with the formData }, onInvalid: errors => { @@ -285,7 +295,7 @@ Identical to the [`FormProvider`](https://react-hook-form.com/api/formprovider/) ```jsx export default function Form() { const methods = useRemixForm(); - + return ( // pass all methods into the context
@@ -304,7 +314,7 @@ Exactly the same as [`useFormContext`](https://react-hook-form.com/api/useformco ```jsx export default function Form() { const methods = useRemixForm(); - + return ( // pass all methods into the context @@ -324,7 +334,7 @@ const NestedInput = () => { ``` -## Support +## Support If you like the project, please consider supporting us by giving a ⭐️ on Github. ## License diff --git a/src/hook/index.tsx b/src/hook/index.tsx index 3adacaa..713dfa0 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -20,7 +20,7 @@ import type { UseFormProps, UseFormReturn, } from "react-hook-form"; -import { createFormData, mergeErrors } from "../utilities"; +import { createFormData, mergeErrors, safeKeys } from "../utilities"; export type SubmitFunctionOptions = Parameters[1]; @@ -33,6 +33,7 @@ export interface UseRemixFormOptions submitConfig?: SubmitFunctionOptions; submitData?: FieldValues; fetcher?: FetcherWithComponents; + shouldResetActionData?: boolean; } export const useRemixForm = ({ @@ -40,14 +41,17 @@ export const useRemixForm = ({ submitConfig, submitData, fetcher, + shouldResetActionData = false, ...formProps }: UseRemixFormOptions) => { const actionSubmit = useSubmit(); const actionData = useActionData(); const submit = fetcher?.submit ?? actionSubmit; - const data: any = fetcher?.data ?? actionData; + const data = fetcher?.data ?? actionData; const methods = useForm(formProps); const navigation = useNavigation(); + const shouldMergeErrors = useIsNewData(data, shouldResetActionData); + // Either it's submitted to an action or submitted to a fetcher (or neither) const isSubmittingForm = navigation.state !== "idle" || (fetcher && fetcher.state !== "idle"); @@ -60,7 +64,7 @@ export const useRemixForm = ({ }); }; const values = methods.getValues(); - const validKeys = Object.keys(values); + const validKeys = safeKeys(values); // eslint-disable-next-line @typescript-eslint/no-empty-function const onInvalid = () => {}; @@ -74,15 +78,15 @@ export const useRemixForm = ({ isValidating, touchedFields, submitCount, - errors, + errors: frontendErrors, isLoading, } = formState; - const formErrors = mergeErrors( - errors, - data?.errors ? data.errors : data, - validKeys, - ); + // remix-hook-forms should only process data errors that conform to react-hook-form error data types. + const errors = + shouldMergeErrors && data?.errors + ? mergeErrors(frontendErrors, data?.errors, validKeys) + : frontendErrors; const isValid = !(Object.keys(errors).length > 0); const isSubmitted = @@ -116,7 +120,7 @@ export const useRemixForm = ({ touchedFields, submitCount, isLoading, - errors: formErrors, + errors, }, }; }; @@ -142,3 +146,54 @@ export const useRemixFormContext = () => { >, }; }; + +/** + * usePrevious takes in a data object and return the previous state of that data + * + * @export + * @template T + * @param {T} data + * @returns {*} + */ +export function usePrevious(data: T) { + const ref = React.useRef(); + + React.useEffect(() => { + ref.current = data; + }, [data]); + + return ref.current; +} + +/** + * useIsNewData will only return data if it is new data. + * This useIsNewData hook is used to maintain react-hook-form default behavior and + * prevents remix default behavior of returning useActionData on every rerender. + * This can be overridden by passing shouldResetActionData: true to useRemixForm, + * and it will restore remix default behavior. + * + * @export + * @template DataType + * @param {DataType} data + * @param {boolean} [shouldResetActionData=false] if set to true will always return data. + * @returns {(DataType | undefined)} + */ +export function useIsNewData< + DataType extends { [key: string]: any } | undefined, +>(data: DataType, shouldResetActionData = false): DataType | undefined { + const oldData = usePrevious(data || {}); + + if (!data || Object.keys(data).length < 1) { + return undefined; + } + + if (shouldResetActionData) { + return data; + } + + if (data === oldData) { + return undefined; + } + + return data; +} diff --git a/src/utilities/index.test.ts b/src/utilities/index.test.ts index 77feae7..7b95e13 100644 --- a/src/utilities/index.test.ts +++ b/src/utilities/index.test.ts @@ -7,6 +7,7 @@ import { isGet, mergeErrors, parseFormData, + safeKeys, validateFormData, } from "./index"; @@ -108,18 +109,63 @@ describe("parseFormData", () => { mockFormData.append("formData", blob); requestFormDataSpy.mockResolvedValueOnce(mockFormData); await expect(parseFormData(request)).rejects.toThrowError( - "Data is not a string" + "Data is not a string", ); }); }); +describe("safeKeys", () => { + it("should return dot syntax and final nested field value key", () => { + const formValues = { + "profile.lastName": "Smith", + firstName: "John", + heading: { text: "Password" }, + }; + const expectedKeys: any = [ + "profile.lastName", + "firstName", + "heading", + "root", + "profile", + "lastName", + ]; + const validKeys = safeKeys(formValues); + + expect(validKeys).toEqual(expectedKeys); + }); + + it("should remove duplicate generated from dot syntax field value keys", () => { + const formValues = { + "profile.lastName": "Smith", + firstName: "John", + heading: { text: "Password" }, + lastName: "smith", + }; + const expectedKeys: any = [ + "profile.lastName", + "firstName", + "heading", + "lastName", + "root", + "profile", + ]; + const validKeys = safeKeys(formValues); + + expect(validKeys).toEqual(expectedKeys); + }); +}); + describe("mergeErrors", () => { it("should return the backend errors if frontend errors is not provided", () => { const backendErrors: any = { username: { message: "This field is required" }, }; - const mergedErrors = mergeErrors({}, backendErrors); - expect(mergedErrors).toEqual(backendErrors); + const expectedErrors: any = { + username: { message: "This field is required", type: "backend" }, + }; + const mergedErrors = mergeErrors({}, backendErrors, ["username"]); + + expect(mergedErrors).toEqual(expectedErrors); }); it("should return the frontend errors if backend errors is not provided", () => { @@ -132,7 +178,9 @@ describe("mergeErrors", () => { const frontendErrors: any = { password: { message: "Password is required" }, confirmPassword: { message: "Passwords do not match" }, - profile: { firstName: { message: "First name is required" } }, + profile: { + firstName: { message: "First name is required" }, + }, }; const backendErrors: any = { confirmPassword: { message: "Password confirmation is required" }, @@ -143,18 +191,159 @@ describe("mergeErrors", () => { }; const expectedErrors = { password: { message: "Password is required" }, - confirmPassword: { message: "Password confirmation is required" }, + confirmPassword: { + message: "Password confirmation is required", + type: "backend", + }, profile: { firstName: { message: "First name is required" }, + lastName: { message: "Last name is required", type: "backend" }, + address: { street: { message: "Street is required", type: "backend" } }, + }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, [ + "password", + "confirmPassword", + "firstName", + "lastName", + "street", + ]); + + expect(mergedErrors).toEqual(expectedErrors); + }); + + it("should ignore backend data that doesn't conform to react-hook-form errors even if the key exists", () => { + const validKeys = ["password", "profile.lastName", "heading", "lastName"]; + const frontendErrors: any = { + password: { message: "required" }, + }; + const backendErrors: any = { + profile: { + heading: { text: "Password" }, lastName: { message: "Last name is required" }, - address: { street: { message: "Street is required" } }, }, }; - const mergedErrors = mergeErrors(frontendErrors, backendErrors); + const expectedErrors = { + profile: { + lastName: { message: "Last name is required", type: "backend" }, + }, + password: { message: "required" }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); + + expect(mergedErrors).toEqual(expectedErrors); + }); + + it("should override backend type with value you from backend response", () => { + const validKeys = ["password", "profile.lastName", "heading", "lastName"]; + const frontendErrors: any = { + password: { message: "required" }, + }; + const backendErrors: any = { + profile: { + heading: { text: "Password" }, + lastName: { message: "Last name is required", type: "required" }, + }, + }; + const expectedErrors = { + profile: { + lastName: { message: "Last name is required", type: "required" }, + }, + password: { message: "required" }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); + + expect(mergedErrors).toEqual(expectedErrors); + }); + + it("should return a backend type error response only", () => { + const validKeys = ["password", "profile.lastName", "heading", "lastName"]; + const frontendErrors: any = { + password: { message: "required" }, + }; + const backendErrors: any = { + profile: { + lastName: { type: "min" }, + }, + }; + const expectedErrors = { + profile: { + lastName: { type: "min" }, + }, + password: { message: "required" }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); + + expect(mergedErrors).toEqual(expectedErrors); + }); + + it("should return backend root errors", () => { + const validKeys: string[] = []; + const frontendErrors: any = {}; + const backendErrors: any = { + root: { message: "root error message" }, + "root.server": { message: "root.server error message" }, + }; + const expectedErrors = { + root: { message: "root error message", type: "backend" }, + "root.server": { message: "root.server error message", type: "backend" }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); + expect(mergedErrors).toEqual(expectedErrors); + }); + it("should return nested backend root errors", () => { + const validKeys = ["root.server", "server", "toastMessage"]; + const frontendErrors: any = {}; + const backendErrors: any = { + root: { + toastMessage: { message: "root error message" }, + server: { message: "root.server error message" }, + }, + "root.server": { message: "root.server error message" }, + }; + const expectedErrors = { + root: { + server: { + message: "root.server error message", + type: "backend", + }, + toastMessage: { + message: "root error message", + type: "backend", + }, + }, + "root.server": { + message: "root.server error message", + type: "backend", + }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); + expect(mergedErrors).toEqual(expectedErrors); + }); + + it("should show backend that just use react-hook-form type messages", () => { + const validKeys = ["password", "firstName", "lastName"]; + const frontendErrors: any = {}; + const backendErrors: any = { + password: { message: "Invalid Password" }, + profile: { + firstName: { type: "required" }, + lastName: { type: "min" }, + }, + }; + const expectedErrors = { + password: { message: "Invalid Password", type: "backend" }, + profile: { + firstName: { type: "required" }, + lastName: { type: "min" }, + }, + }; + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); expect(mergedErrors).toEqual(expectedErrors); }); it("should overwrite the frontend error message with the backend error message", () => { + const validKeys = ["username"]; const frontendErrors: any = { username: { message: "This field is required" }, }; @@ -162,9 +351,9 @@ describe("mergeErrors", () => { username: { message: "The username is already taken" }, }; const expectedErrors = { - username: { message: "The username is already taken" }, + username: { message: "The username is already taken", type: "backend" }, }; - const mergedErrors = mergeErrors(frontendErrors, backendErrors); + const mergedErrors = mergeErrors(frontendErrors, backendErrors, validKeys); expect(mergedErrors).toEqual(expectedErrors); }); }); @@ -297,7 +486,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 +499,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: { @@ -338,7 +527,7 @@ describe("getValidatedFormData", () => { }); const formData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(formData).toStrictEqual({ data: { @@ -386,7 +575,7 @@ describe("getValidatedFormData", () => { }); const validatedFormData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(validatedFormData).toStrictEqual({ data: formData, @@ -420,7 +609,7 @@ describe("getValidatedFormData", () => { }); const returnData = await getValidatedFormData( request as any, - zodResolver(schema) + zodResolver(schema), ); expect(returnData).toStrictEqual({ data: { diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 3e895f1..ec0164c 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -2,8 +2,7 @@ import { FieldValues, Resolver, FieldErrors, - FieldErrorsImpl, - DeepRequired, + FieldError, } from "react-hook-form"; /** @@ -86,7 +85,7 @@ export const isGet = (request: Pick) => */ export const getValidatedFormData = async ( request: Request, - resolver: Resolver + resolver: Resolver, ) => { const data = isGet(request) ? getFormDataFromSearchParams(request) @@ -103,12 +102,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) { @@ -126,7 +125,7 @@ export const validateFormData = async ( */ export const createFormData = ( data: T, - key = "formData" + key = "formData", ): FormData => { const formData = new FormData(); const finalData = JSON.stringify(data); @@ -143,7 +142,7 @@ Parses the specified Request object's FormData to retrieve the data associated w */ export const parseFormData = async ( request: Request, - key = "formData" + key = "formData", ): Promise => { const formData = await request.formData(); const data = formData.get(key); @@ -158,46 +157,114 @@ export const parseFormData = async ( return JSON.parse(data); }; -/** +/** 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. @param frontendErrors - The frontend errors @param backendErrors - The backend errors @returns The merged errors of type Partial>>. */ -export const mergeErrors = ( - frontendErrors: Partial>>, - backendErrors?: Partial>>, - validKeys: string[] = [], - depth = 0 +export const mergeErrors = ( + frontendErrors: FieldErrors, + backendErrors?: any, + validKeys: string[] | undefined = [], + errorSet: boolean | undefined = false, ) => { if (!backendErrors) { return frontendErrors; } - for (const [key, rightValue] of Object.entries(backendErrors) as [ - keyof T, - DeepRequired[keyof T] - ][]) { + for (const [key, rightValue] of Object.entries(backendErrors)) { if ( - !validKeys.includes(key.toString()) && - validKeys.length && - depth === 0 + typeof rightValue === "object" && + !rightValue?.message && + !rightValue?.type && + !Array.isArray(rightValue) ) { - continue; - } - if (typeof rightValue === "object" && !Array.isArray(rightValue)) { if (!frontendErrors[key]) { - frontendErrors[key] = {} as DeepRequired[keyof T]; + frontendErrors[key] = {}; + } + mergeErrors( + frontendErrors[key]! as FieldErrors, + rightValue, + validKeys, + errorSet, + ); + const hasNoMessage = Object.keys(frontendErrors[key] || {}).length < 1; + // removes the base object since no error was set. + if (!errorSet && hasNoMessage) { + delete frontendErrors[key]; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mergeErrors(frontendErrors[key]!, rightValue, validKeys, depth + 1); } else if (rightValue) { - frontendErrors[key] = rightValue; + if ( + (validKeys.includes(key) || key.toLowerCase().startsWith("root")) && + (typeof rightValue === "string" || + rightValue?.message || + rightValue?.type) + ) { + frontendErrors[key] = formatError(rightValue); + if (frontendErrors[key]) errorSet = true; + } } } return frontendErrors; }; + +/** + * formatError maintains react-hook-form message standard formate. + * + * @param {*} error + * @returns {*} + */ +export const formatError = ( + error: FieldErrors[K] | string, +): FieldError | undefined => { + // Turns a string error into a valid react-hook-form error message + if (typeof error === "string") { + return { + message: error, + type: "backend", + }; + } + + // allows for no message react-hook-form error messages + if (typeof error?.type === "string" || typeof error?.message === "string") { + const { message, type, ...errorProps } = error as FieldError; + + return { + message, + type: type || "backend", + ...errorProps, + }; + } + + // We return undefined since message and type are not set, + // there is no valid react-hook-form error message being sent + return undefined; +}; + +/** + * safeKeys will append the keys from dot syntax FieldValues to the key list. Including the excepted root key. + * + * Note: This keeps the dot syntax version too, just incase the user sends that back from the back end. + * + * @param {object} values + * @returns {string[]} + */ +export const safeKeys = (values: object) => { + const validKeys = Object.keys(values); + let dotSyntaxKeys: string[] = ["root"]; + + validKeys.forEach((key) => { + if (key.includes(".")) { + const keys = key.split("."); + dotSyntaxKeys = dotSyntaxKeys.concat(keys); + } + }); + + return [...new Set(validKeys.concat(dotSyntaxKeys))]; +};