diff --git a/jest.config.cjs b/jest.config.cjs index d12100b..7ab7d70 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,6 +1,9 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: "ts-jest", + testTimeout: process.env.JEST_TIMEOUT + ? parseInt(process.env.JEST_TIMEOUT) + : undefined, testEnvironment: "jsdom", testPathIgnorePatterns: ["utils", "lib"], restoreMocks: true, diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 79c7174..d5c1956 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -73,6 +73,11 @@ export function FieldContextProvider({ ); } +export function useMaybeFieldName() { + const context = useContext(FieldContext); + return context?.name; +} + function useContextProt(name: string) { const context = useContext(FieldContext); if (!context) @@ -106,7 +111,7 @@ export function useTsController() { ? DeepPartial | undefined : FieldType | undefined; // Just gives better types to useController - const controller = useController(context) as any as Omit< + const controller = useController(context) as unknown as Omit< UseControllerReturn, "field" > & { @@ -471,4 +476,4 @@ export function useNumberFieldInfo() { }, "useNumberFieldInfo" ); -} \ No newline at end of file +} diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 51fd586..87c7aa5 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -3,7 +3,13 @@ import { z } from "zod"; import { render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { + BooleanField, customFieldTestId, + defaultBooleanInputTestId, + defaultNumberInputTestId, + defaultTextInputTestId, + errorMessageTestId, + NumberField, TestCustomFieldSchema, TestForm, TestFormWithSubmit, @@ -11,7 +17,9 @@ import { textFieldTestId, } from "./utils/testForm"; import { + PropType, createTsForm, + createTsFormAndFragment, noMatchingSchemaErrorMessage, useFormResultValueChangedErrorMesssage, } from "../createSchemaForm"; @@ -22,6 +30,7 @@ import { import { Control, useController, + useFieldArray, useForm, useFormState, useWatch, @@ -1330,8 +1339,7 @@ describe("createSchemaForm", () => { const defaultEmail = "john@example.com"; const DefaultTextField = () => { - // @ts-expect-error - const { defaultValue, type, zodType } = useFieldInfo(); + const { defaultValue } = useFieldInfo(); expect(defaultValue).toBe(defaultEmail); @@ -1577,7 +1585,7 @@ describe("createSchemaForm", () => { nestedField: { text: "name", age: 9 }, nestedField2: { bool: true }, }; - // TODO: test validation + render(
{ expect(textNodes).toBeInTheDocument(); const numberNodes = screen.queryByText("number"); expect(numberNodes).toBeInTheDocument(); - expect(screen.queryByTestId("error")).toHaveTextContent(""); + expect(screen.queryByTestId("error")).toBeEmptyDOMElement(); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); it("should render two copies of an object schema if in an unmapped array schema", async () => { const NumberSchema = createUniqueFieldSchema(z.number(), "number"); const mockOnSubmit = jest.fn(); - function TextField({}: { a?: 1 }) { - return
text
; - } - - function NumberField() { - return
number
; - } - function ObjectField({ objProp }: { objProp: 2 }) { return
{objProp}
; } @@ -1648,7 +1648,13 @@ describe("createSchemaForm", () => { onSubmit={mockOnSubmit} defaultValues={defaultValues} // otherObj tests that nonrecursive mapping still works at the last level of the recursion depth - props={{ arrayField: { text: { a: 1 }, otherObj: { objProp: 2 } } }} + props={{ + arrayField: { + // this tests that the prop actually makes it to the component not just the type + text: { testId: "recursive-custom-text" }, + otherObj: { objProp: 2 }, + }, + }} renderAfter={() => { return ; }} @@ -1670,11 +1676,11 @@ describe("createSchemaForm", () => {
); - const textNodes = screen.queryAllByText("text"); + const textNodes = screen.queryAllByTestId("recursive-custom-text"); textNodes.forEach((node) => expect(node).toBeInTheDocument()); expect(textNodes).toHaveLength(2); - const numberNodes = screen.queryAllByText("number"); + const numberNodes = screen.queryAllByTestId(defaultNumberInputTestId); numberNodes.forEach((node) => expect(node).toBeInTheDocument()); expect(numberNodes).toHaveLength(2); @@ -1855,84 +1861,636 @@ describe("createSchemaForm", () => { const inputs = screen.getAllByTestId(/dynamic-array-input/); expect(inputs.length).toBe(3); }); - describe("CustomChildRenderProp", () => { - it("should not drop focus on rerender", async () => { + describe("FormFragment", () => { + it("should provide a nested renderer for use in complex components", async () => { + const mockOnSubmit = jest.fn(); + + function ComplexField({ complexProp1 }: { complexProp1: boolean }) { + return ( +
+ {complexProp1 &&
complexProp1
} + +
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), + }); + + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [objectSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + const schema = z.object({ - fieldOne: z.string().regex(/moo/), - fieldTwo: z.string(), + nestedField: objectSchema, + }); + const defaultValues = { + nestedField: { num: 4, str: "this" }, + }; + const form = ( +
} + /> + ); + const { rerender } = render(form); + const button = screen.getByText("submit"); + await userEvent.click(button); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + expect(screen.queryByText("complexProp1")).toBeInTheDocument(); + const textNodes = screen.queryByTestId(defaultTextInputTestId); + expect(textNodes).toBeInTheDocument(); + expect(textNodes).toHaveDisplayValue("this"); + const numberNodes = screen.queryByTestId(defaultNumberInputTestId); + expect(numberNodes).toBeInTheDocument(); + expect(numberNodes).toHaveDisplayValue("4"); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); + it("should allow deep rendering", async () => { + const mockOnSubmit = jest.fn(); + function ComplexField({ complexProp1 }: { complexProp1: boolean }) { + const { + field: { value }, + } = useTsController>(); + return ( +
+ {complexProp1 &&
complexProp1
} +
{value?.displayValue}
+ +
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), }); - const Form = createTsForm([[z.string(), TextField]] as const, { - FormComponent: ({ - children, - }: { - onSubmit: () => void; - children: ReactNode; - }) => { - const { isSubmitting } = useFormState(); - return ( - - {children} - {isSubmitting} - - ); + const deepObjectSchema = z.object({ + nestedLevel2: objectSchema, + displayValue: z.string(), + }); + + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [deepObjectSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField: deepObjectSchema, + }); + const defaultValues = { + nestedField: { + nestedLevel2: { num: 4, str: "this" }, + displayValue: "Yay", }, + }; + const form = ( +
} + /> + ); + const { rerender } = render(form); + + expect(screen.queryByText("Yay")).toBeInTheDocument(); + const textNode = screen.queryByTestId(defaultTextInputTestId); + if (!textNode) { + throw new Error("textNode is null"); + } + expect(screen.queryByText("complexProp1")).toBeInTheDocument(); + expect(textNode).toBeInTheDocument(); + expect(textNode).toHaveDisplayValue("this"); + await userEvent.type(textNode, "2"); + expect(textNode).toHaveDisplayValue("this2"); + const numberNodes = screen.queryByTestId(defaultNumberInputTestId); + expect(numberNodes).toBeInTheDocument(); + expect(numberNodes).toHaveDisplayValue("4"); + + const button = screen.getByText("submit"); + await userEvent.click(button); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith({ + ...defaultValues, + nestedField: { + ...defaultValues.nestedField, + nestedLevel2: { + ...defaultValues.nestedField.nestedLevel2, + str: "this2", + }, + }, + }); + }); + //TODO: add props to the nested fields and not just custom props of the component + it("should render dynamic arrays", async () => { + const mockOnSubmit = jest.fn(); + const addedValue = { num: 3, str: "this2" }; + function ComplexField({ + complexProp1, + ...restProps + }: { complexProp1: boolean } & PropType< + typeof mapping, + typeof objectSchema + >) { + const { + field: { value, onChange }, + } = useTsController>(); + return ( +
+ {complexProp1 &&
complexProp1
} + {value?.map((_val, i) => { + return ( + + ); + })} + + +
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), }); - const TestComponent = () => { - const form = useForm>({ - mode: "onChange", - resolver: zodResolver(schema), - }); - const values = { - ...form.getValues(), - ...useWatch({ control: form.control }), - }; + const complexSchema = z.array(objectSchema); + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [complexSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + const schema = z.object({ + nestedField: complexSchema, + }); + const defaultValues = { + nestedField: [{ num: 4, str: "this" }], + }; + const form = ( + } + /> + ); + const { rerender } = render(form); + await userEvent.click(screen.getByText("Add item")); + await userEvent.click(screen.getByText("submit")); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + + const basicNodes = [ + ...screen.queryAllByTestId(defaultTextInputTestId), + ...screen.queryAllByTestId(defaultNumberInputTestId), + screen.queryByText("complexProp1"), + ...screen.queryAllByText("%"), + ]; + expect(basicNodes).toHaveLength(7); + basicNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(basicNodes[0]).toHaveDisplayValue("this"); + expect(basicNodes[2]).toHaveDisplayValue("4"); + expect(basicNodes[1]).toHaveDisplayValue(addedValue.str); + expect(basicNodes[3]).toHaveDisplayValue(addedValue.num.toString()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith({ + ...defaultValues, + nestedField: [...defaultValues.nestedField, addedValue], + }); + await userEvent.click(screen.getByText("Remove item")); + const afterRemoveNodes = [ + ...screen.queryAllByTestId(defaultTextInputTestId), + ...screen.queryAllByTestId(defaultNumberInputTestId), + ]; + expect(afterRemoveNodes).toHaveLength(2); + afterRemoveNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(afterRemoveNodes[0]).toHaveDisplayValue("this"); + expect(afterRemoveNodes[1]).toHaveDisplayValue("4"); + }); + + it("should be able to render dynamic arrays with useFieldArray for performance", async () => { + const mockOnSubmit = jest.fn(); + const addedValue = { num: 3, str: "this2" }; + function ComplexField({ name }: { complexProp1: boolean; name: string }) { + const { fields: value, append, remove } = useFieldArray({ name }); return ( - Moo{JSON.stringify(values)}, - }, - fieldTwo: { testId: "fieldTwo" }, - }} - onSubmit={() => {}} - > - {(fields) => { - const { isDirty } = useFormState(); - const [state, setState] = useState(0); - useEffect(() => { - setState(1); - }, []); +
+ {value?.map((_val, i) => { return ( - <> - {Object.values(fields)} -
{JSON.stringify(isDirty)}
-
{state}
- + ); - }} - + })} + + +
); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), + }); + + const complexSchema = z.array(objectSchema); + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [complexSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField: complexSchema, + }); + const defaultValues = { + nestedField: [{ num: 4, str: "this" }], }; - render(); - const fieldOne = screen.queryByTestId("fieldOne"); - if (!fieldOne) throw new Error("fieldOne not found"); - fieldOne.focus(); - expect(fieldOne).toHaveFocus(); - await userEvent.type(fieldOne, "t"); - expect(fieldOne).toHaveFocus(); - await userEvent.type(fieldOne, "2"); - expect(fieldOne).toHaveFocus(); - // verify that context and stateful hooks still work - expect(screen.queryByTestId("dirty")).toHaveTextContent("true"); - expect(screen.queryByTestId("state")).toHaveTextContent("1"); - screen.debug(); + const form = ( +
} + /> + ); + + const { rerender } = render(form); + await userEvent.click(screen.getByText("Add item")); + await userEvent.click(screen.getByText("submit")); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + const basicNodes = [ + ...screen.queryAllByTestId(defaultTextInputTestId), + ...screen.queryAllByTestId(defaultNumberInputTestId), + ]; + expect(basicNodes).toHaveLength(4); + basicNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(basicNodes[0]).toHaveDisplayValue("this"); + expect(basicNodes[2]).toHaveDisplayValue("4"); + expect(basicNodes[1]).toHaveDisplayValue(addedValue.str); + expect(basicNodes[3]).toHaveDisplayValue(addedValue.num.toString()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith({ + ...defaultValues, + nestedField: [...defaultValues.nestedField, addedValue], + }); + await userEvent.click(screen.getByText("Remove item")); + const afterRemoveNodes = [ + ...screen.queryAllByTestId(defaultTextInputTestId), + ...screen.queryAllByTestId(defaultNumberInputTestId), + ]; + expect(afterRemoveNodes).toHaveLength(2); + afterRemoveNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(afterRemoveNodes[0]).toHaveDisplayValue("this"); + expect(afterRemoveNodes[1]).toHaveDisplayValue("4"); + }); + + it("should be able to split up and reorder complex schemas", async () => { + const mockOnSubmit = jest.fn(); + function ComplexField({}: { complexProp1: boolean }) { + return ( +
+
+ Number and boolean in a row + +
+
+ String fields in a row + +
+
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), + bool: z.boolean(), + }); + + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [z.boolean(), BooleanField], + [objectSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField: objectSchema, + }); + const defaultValues = { + nestedField: { num: 4, str: "this", bool: true }, + }; + const form = ( + } + /> + ); + const { rerender } = render(form); + const button = screen.getByText("submit"); + await userEvent.click(button); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + const textNodes = screen.queryByTestId(defaultTextInputTestId); + expect(textNodes).toBeInTheDocument(); + expect(textNodes).toHaveDisplayValue("this"); + const numberNodes = screen.queryByTestId(defaultNumberInputTestId); + expect(numberNodes).toBeInTheDocument(); + expect(numberNodes).toHaveDisplayValue("4"); + expect(screen.queryByText("%")).toBeInTheDocument(); + const booleanNodes = screen.queryByTestId(defaultBooleanInputTestId); + expect(booleanNodes).toBeInTheDocument(); + expect(booleanNodes).toBeChecked(); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); + it("should render recursive object schemas", async () => { + const mockOnSubmit = jest.fn(); + function RecursiveObjectField({}: { complexProp1?: boolean }) { + const { + field: { value }, + } = useTsController>(); + return !!value?.hideThisNode ? ( + <> + ) : ( +
+ {JSON.stringify(value)} + +
+ ); + } + + function RecursiveObjectArrayField({}: {}) { + const { + field: { value }, + } = + useTsController>(); + return ( + <> + {value?.map((_obj, i) => ( + + ))} + + ); + } + + const baseObjectSchema = z.object({ + num: z.number(), + str: z.string(), + hideThisNode: z.boolean().optional(), + }); + + type ObjectType = z.infer & { + objects?: ObjectType[]; + }; + + type ZodObjectWithShape = z.ZodObject< + S, + "strip", + z.ZodTypeAny, + T, + T + >; + + type ObjectShape = (typeof baseObjectSchema)["shape"] & { + objects: z.ZodOptional< + z.ZodLazy>> + >; + }; + + type RecursiveObjectSchema = ZodObjectWithShape; + + const recursiveObjectSchema: RecursiveObjectSchema = + baseObjectSchema.extend({ + objects: z.lazy(() => recursiveObjectSchema.array()).optional(), + }); + + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [z.boolean(), BooleanField], + [recursiveObjectSchema, RecursiveObjectField], + [recursiveObjectSchema.array(), RecursiveObjectArrayField], + ] as const; + + const [Form, FormFragment, FormFragmentField] = + createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField: recursiveObjectSchema, + }); + const defaultValues = { + nestedField: { + num: 4, + str: "this", + hideThisNode: false, + objects: [ + { + num: 5, + str: "whatever", + hideThisNode: true, + objects: [{ num: 6, str: "whatever2" }], + }, + ], + }, + }; + const form = ( + } + /> + ); + const { rerender } = render(form); + const button = screen.getByText("submit"); + await userEvent.click(button); + // this rerender is currently needed because setError seemingly doesn't rerender the component using useController + rerender(form); + const textNodes = screen.queryByTestId(defaultTextInputTestId); + expect(textNodes).toBeInTheDocument(); + expect(textNodes).toHaveDisplayValue("this"); + const numberNodes = screen.queryByTestId(defaultNumberInputTestId); + expect(numberNodes).toBeInTheDocument(); + expect(numberNodes).toHaveDisplayValue("4"); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); }); }); + +describe("CustomChildRenderProp", () => { + it("should not drop focus on rerender", async () => { + const schema = z.object({ + fieldOne: z.string().regex(/moo/), + fieldTwo: z.string(), + }); + + const Form = createTsForm([[z.string(), TextField]] as const, { + FormComponent: ({ + children, + }: { + onSubmit: () => void; + children: ReactNode; + }) => { + const { isSubmitting } = useFormState(); + return ( + + {children} + {isSubmitting} + + ); + }, + }); + + const TestComponent = () => { + const form = useForm>({ + mode: "onChange", + resolver: zodResolver(schema), + }); + const values = { + ...form.getValues(), + ...useWatch({ control: form.control }), + }; + + return ( +
Moo{JSON.stringify(values)}, + }, + fieldTwo: { testId: "fieldTwo" }, + }} + onSubmit={() => {}} + > + {(fields) => { + const { isDirty } = useFormState(); + const [state, setState] = useState(0); + useEffect(() => { + setState(1); + }, []); + return ( + <> + {Object.values(fields)} +
{JSON.stringify(isDirty)}
+
{state}
+ + ); + }} +
+ ); + }; + render(); + const fieldOne = screen.queryByTestId("fieldOne"); + if (!fieldOne) throw new Error("fieldOne not found"); + fieldOne.focus(); + expect(fieldOne).toHaveFocus(); + await userEvent.type(fieldOne, "t"); + expect(fieldOne).toHaveFocus(); + await userEvent.type(fieldOne, "2"); + expect(fieldOne).toHaveFocus(); + // verify that context and stateful hooks still work + expect(screen.queryByTestId("dirty")).toHaveTextContent("true"); + expect(screen.queryByTestId("state")).toHaveTextContent("1"); + }); +}); diff --git a/src/__tests__/utils/testForm.tsx b/src/__tests__/utils/testForm.tsx index d131ff6..fca2e48 100644 --- a/src/__tests__/utils/testForm.tsx +++ b/src/__tests__/utils/testForm.tsx @@ -3,8 +3,20 @@ import { Control, useController } from "react-hook-form"; import { z } from "zod"; import { createUniqueFieldSchema } from "../../createFieldSchema"; import { createTsForm } from "../../createSchemaForm"; +import { useTsController } from "../../FieldContext"; export const textFieldTestId = "text-field"; +export const defaultTextInputTestId = "text-input"; +export const defaultBooleanInputTestId = "boolean-input"; +export const defaultNumberInputTestId = "number-input"; +export const errorMessageTestId = "error-message"; + +export function ErrorMessage() { + const { error } = useTsController(); + return !error ? null : ( +
{error?.errorMessage}
+ ); +} export function TextField(props: { control: Control; @@ -17,35 +29,72 @@ export function TextField(props: { const { field: { onChange, value }, } = useController({ control: props.control, name: props.name }); + return (
{label && } { onChange(e.target.value); }} value={value ? value : ""} placeholder={placeholder} /> +
); } -function BooleanField(props: { +export function BooleanField(props: { control: Control; name: string; - testId: string; + testId?: string; }) { - return ; + const { + field: { onChange, value }, + } = useController({ control: props.control, name: props.name }); + return ( +
+ { + onChange(e.target.checked); + }} + /> + +
+ ); } -function NumberField(props: { +export function NumberField(props: { control: Control; name: string; - testId: string; + testId?: string; + suffix?: string; }) { - return ; + const { + field: { onChange, value }, + } = useController({ control: props.control, name: props.name }); + return ( +
+ { + onChange(e.target.value); + }} + /> +
{props.suffix}
+ +
+ ); } export const customFieldTestId = "custom"; @@ -59,6 +108,7 @@ function CustomTextField(props: { return (
+
); } diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 4685275..d15bd70 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -4,6 +4,8 @@ import React, { ReactElement, ReactNode, RefAttributes, + createContext, + useContext, useEffect, useRef, } from "react"; @@ -13,6 +15,7 @@ import { ErrorOption, FormProvider, useForm, + useFormContext, UseFormReturn, } from "react-hook-form"; import { @@ -34,7 +37,7 @@ import { import { getMetaInformationForZodType } from "./getMetaInformationForZodType"; import { unwrapEffects } from "./unwrap"; import { RTFBaseZodType, RTFSupportedZodTypes } from "./supportedZodTypes"; -import { FieldContextProvider } from "./FieldContext"; +import { FieldContextProvider, useMaybeFieldName } from "./FieldContext"; import { isZodTypeEqual } from "./isZodTypeEqual"; import { duplicateTypeError, printWarningsForSchema } from "./logging"; import { @@ -244,24 +247,18 @@ export type CustomChildRenderProp = ( fieldMap: RenderedFieldMap ) => ReactElement | null; -export type RTFFormProps< - Mapping extends FormComponentMapping, +export type RTFFormSpecificProps< SchemaType extends z.AnyZodObject | ZodEffects, - PropsMapType extends PropsMapping = typeof defaultPropsMap, FormType extends FormComponent = "form" > = { /** - * A Zod Schema - An input field will be rendered for each property in the schema, based on the mapping passed to `createTsForm` + * Initializes your form with default values. Is a deep partial, so all properties and nested properties are optional. */ - schema: SchemaType; + defaultValues?: DeepPartial>>; /** * A callback function that will be called with the data once the form has been submitted and validated successfully. */ onSubmit: RTFFormSubmitFn; - /** - * Initializes your form with default values. Is a deep partial, so all properties and nested properties are optional. - */ - defaultValues?: DeepPartial>>; /** * A function that renders components after the form, the function is passed a `submit` function that can be used to trigger * form submission. @@ -298,6 +295,26 @@ export type RTFFormProps< * ``` */ form?: UseFormReturn>; +} & RequireKeysWithRequiredChildren<{ + /** + * Props to pass to the form container component (by default the props that "form" tags accept) + */ + formProps?: DistributiveOmit< + ComponentProps, + "children" | "onSubmit" + >; +}>; + +export type RTFSharedFormProps< + Mapping extends FormComponentMapping, + SchemaType extends z.AnyZodObject | ZodEffects, + PropsMapType extends PropsMapping = typeof defaultPropsMap +> = { + /** + * A Zod Schema - An input field will be rendered for each property in the schema, based on the mapping passed to `createTsForm` + */ + schema: SchemaType; + children?: CustomChildRenderProp; } & RequireKeysWithRequiredChildren<{ /** @@ -316,16 +333,97 @@ export type RTFFormProps< * ``` */ props?: PropType; -}> & - RequireKeysWithRequiredChildren<{ - /** - * Props to pass to the form container component (by default the props that "form" tags accept) - */ - formProps?: DistributiveOmit< - ComponentProps, - "children" | "onSubmit" - >; - }>; +}>; + +export type RTFFormProps< + Mapping extends FormComponentMapping, + SchemaType extends z.AnyZodObject | ZodEffects, + PropsMapType extends PropsMapping = typeof defaultPropsMap, + FormType extends FormComponent = "form" +> = RTFSharedFormProps & + RTFFormSpecificProps; + +export type TsForm< + Mapping extends FormComponentMapping, + PropsMapType extends PropsMapping, + FormType extends FormComponent +> = ( + props: RTFFormProps +) => React.ReactElement; + +export type TsFormCreateOptions< + FormType extends FormComponent, + PropsMapType extends PropsMapping +> = { + /** + * The component to wrap your fields in. By default, it is a `
`. + * @example + * ```tsx + * function MyCustomFormContainer({children, onSubmit}:{children: ReactNode, onSubmit: ()=>void}) { + * return ( + * + * {children} + * + *
+ * ) + * } + * const MyForm = createTsForm(mapping, { + * FormComponent: MyCustomFormContainer + * }) + * ``` + */ + FormComponent?: FormType; + /** + * Modify which props the form control and such get passed to when rendering components. This can make it easier to integrate existing + * components with `@ts-react/form` or modify its behavior. The values of the object are the names of the props to forward the corresponding + * data to. + * @default + * { + * name: "name", + * control: "control", + * enumValues: "enumValues", + * } + * @example + * ```tsx + * function MyTextField({someControlProp}:{someControlProp: Control}) { + * //... + * } + * + * const createTsForm(mapping, { + * propsMap: { + * control: "someControlProp" + * } + * }) + * ``` + */ + propsMap?: PropsMapType; +}; + +export function createTsForm< + Mapping extends FormComponentMapping, + PropsMapType extends PropsMapping = typeof defaultPropsMap, + FormType extends FormComponent = "form" +>( + /** + * An array mapping zod schemas to components. + * @example + * ```tsx + * const mapping = [ + * [z.string(), TextField] as const + * [z.boolean(), CheckBoxField] as const + * ] as const + * + * const MyForm = createTsForm(mapping); + * ``` + */ + componentMap: Mapping, + /** + * Options to customize your form. + */ + options?: TsFormCreateOptions +): TsForm { + return createTsFormAndFragment(componentMap, options)[0]; +} /** * Creates a reusable, typesafe form component based on a zod-component mapping. @@ -339,7 +437,7 @@ export type RTFFormProps< * @param componentMap A zod-component mapping. An array of 2-tuples where the first element is a zod schema and the second element is a React Functional Component. * @param options Optional - A custom form component to use as the container for the input fields. */ -export function createTsForm< +export function createTsFormAndFragment< Mapping extends FormComponentMapping, PropsMapType extends PropsMapping = typeof defaultPropsMap, FormType extends FormComponent = "form" @@ -360,62 +458,15 @@ export function createTsForm< /** * Options to customize your form. */ - options?: { - /** - * The component to wrap your fields in. By default, it is a `
`. - * @example - * ```tsx - * function MyCustomFormContainer({children, onSubmit}:{children: ReactNode, onSubmit: ()=>void}) { - * return ( - * - * {children} - * - *
- * ) - * } - * const MyForm = createTsForm(mapping, { - * FormComponent: MyCustomFormContainer - * }) - * ``` - */ - FormComponent?: FormType; - /** - * Modify which props the form control and such get passed to when rendering components. This can make it easier to integrate existing - * components with `@ts-react/form` or modify its behavior. The values of the object are the names of the props to forward the corresponding - * data to. - * @default { - * name: "name", - * control: "control", - * enumValues: "enumValues", - * } - * @example - * ```tsx - * function MyTextField({someControlProp}:{someControlProp: Control}) { - * //... - * } - * - * const createTsForm(mapping, { - * propsMap: { - * control: "someControlProp" - * } - * }) - * ``` - */ - propsMap?: PropsMapType; - } -): ( - props: RTFFormProps -) => React.ReactElement { - const ActualFormComponent = options?.FormComponent - ? options.FormComponent - : "form"; + options?: TsFormCreateOptions +) { const schemas = componentMap.map((e) => e[0]); checkForDuplicateTypes(schemas); checkForDuplicateUniqueFields(schemas); - const propsMap = propsMapToObect( - options?.propsMap ? options.propsMap : defaultPropsMap - ); - return function Component({ + const propsMap = propsMapToObect(options?.propsMap ?? defaultPropsMap); + const FormComponent = options?.FormComponent || "form"; + + function TsForm({ schema, onSubmit, props, @@ -447,7 +498,7 @@ export function createTsForm< form.reset(defaultValues); } }, []); - const { control, handleSubmit, setError, getValues } = _form; + const { handleSubmit, setError } = _form; const submitter = useSubmitter({ resolver, onSubmit, @@ -455,13 +506,48 @@ export function createTsForm< }); const submitFn = handleSubmit(submitter.submit); + return ( + + + + {renderAfter?.({ submit: submitFn })} + + {renderBefore?.({ submit: submitFn })} + + + + ); + } + + type RenderFieldProps = { + schema: Type; + props: PropType; + // when a number schemaKey is assumed to be an array index + schemaKey: string | number; + form: UseFormReturn, any>; + namePrefix: string | undefined; + submitter: Submitter; + }; + + function renderField({ + schema, + props, + schemaKey, + form: { control, getValues }, + namePrefix, + submitter, + }: RenderFieldProps): RenderedElement { function renderComponentForSchemaDeep< - NestedSchemaType extends RTFSupportedZodTypes | ZodEffects, - K extends keyof z.infer> + NestedSchemaType extends RTFSupportedZodTypes | ZodEffects >( _type: NestedSchemaType, props: PropType | undefined, - key: K, prefixedKey: string, currentValue: any ): RenderedElement { @@ -474,7 +560,6 @@ export function createTsForm< accum[subKey] = renderComponentForSchemaDeep( subType, props && props[subKey] ? (props[subKey] as any) : undefined, - subKey, `${prefixedKey}.${subKey}`, currentValue && currentValue[subKey] ); @@ -487,7 +572,6 @@ export function createTsForm< return renderComponentForSchemaDeep( type.element, props, - key, `${prefixedKey}[${index}]`, item ); @@ -495,16 +579,15 @@ export function createTsForm< ); } throw new Error( - noMatchingSchemaErrorMessage(key.toString(), type._def.typeName) + noMatchingSchemaErrorMessage( + prefixedKey.toString(), + type._def.typeName + ) ); } const meta = getMetaInformationForZodType(type); - // TODO: we could define a LeafType in the recursive PropType above that only gets applied when we have an actual mapping then we could typeguard to it or cast here - // until then this thinks (correctly) that fieldProps might not have beforeElement, afterElement at this level of the prop tree - const fieldProps = props && props[key] ? (props[key] as any) : {}; - - const { beforeElement, afterElement } = fieldProps; + const { beforeElement, afterElement } = (props ?? {}) as ExtraProps; const mergedProps = { ...(propsMap.name && { [propsMap.name]: prefixedKey }), @@ -518,7 +601,7 @@ export function createTsForm< ...(propsMap.descriptionPlaceholder && { [propsMap.descriptionPlaceholder]: meta.description?.placeholder, }), - ...fieldProps, + ...props, }; const ctxLabel = meta.description?.label; const ctxPlaceholder = meta.description?.placeholder; @@ -542,6 +625,55 @@ export function createTsForm< ); } + const name = [namePrefix, stringifySchemaKey(schemaKey)] + .filter(Boolean) + .join(typeof schemaKey === "number" ? "" : "."); + return renderComponentForSchemaDeep( + schema, + props as any, + name, + getValues()[name] + ); + } + + function FormFragmentField< + Type extends RTFSupportedZodTypes | ZodEffects + >( + props: Pick, "schema" | "schemaKey"> & + RequireKeysWithRequiredChildren<{ + props?: PropType; + }> + ) { + return ( + <> + {flattenRenderedElements( + renderField({ + ...props, + // TS can't understand that props will be required when necessary because of the generic + props: props.props!!, + form: useFormContext(), + namePrefix: useMaybeFieldName(), + submitter: useSubmitterContext(), + }) + )} + + ); + } + + function FormFragment({ + schema, + props, + children, + schemaKey, + }: RTFSharedFormProps & { + // when a number schemaKey is assumed to be an array index + schemaKey?: string | number; + }) { + const form = useFormContext>(); + + const namePrefix = useMaybeFieldName(); + const submitter = useSubmitterContext(); + function renderFields( schema: SchemaType, props: PropType | undefined @@ -550,16 +682,20 @@ export function createTsForm< const _schema = unwrapEffects(schema); const shape: Record = _schema._def.shape(); return Object.entries(shape).reduce( - (accum, [key, type]: [SchemaKey, RTFSupportedZodTypes]) => { + (accum, [key, subSchema]: [SchemaKey, RTFSupportedZodTypes]) => { // we know this is a string but TS thinks it can be number and symbol so just in case stringify const stringKey = key.toString(); - accum[stringKey] = renderComponentForSchemaDeep( - type, - props as any, - stringKey, - stringKey, - getValues()[key] - ); + const fieldProps = props && props[key] ? props[key] : undefined; + accum[stringKey] = renderField({ + form, + schema: subSchema, + props: fieldProps as any, + namePrefix, + submitter, + schemaKey: [stringifySchemaKey(schemaKey), stringKey] + .filter(Boolean) + .join("."), + }); return accum; }, {} as RenderedObjectElements @@ -568,19 +704,18 @@ export function createTsForm< const renderedFields = renderFields(schema, props); return ( - - - {renderBefore && renderBefore({ submit: submitFn })} - - - {renderAfter && renderAfter({ submit: submitFn })} - - + <> + + ); - }; + } + + function stringifySchemaKey(schemaKey: string | number | undefined) { + return typeof schemaKey == "number" ? `[${schemaKey}]` : schemaKey; + } // these needs to at least have one component wrapping it or the context won't propogate // i believe that means any hooks used in the CustomChildRenderProp are really tied to the lifecycle of this Children component... 😬 @@ -600,7 +735,10 @@ export function createTsForm< ); } + + return [TsForm, FormFragment, FormFragmentField] as const; } + // handles internal custom submit logic // Implements a workaround to allow devs to set form values to undefined (as it breaks react hook form) // For example https://github.com/react-hook-form/react-hook-form/discussions/2797 @@ -656,6 +794,25 @@ function useSubmitter({ addToCoerceUndefined, }; } +type Submitter = ReturnType; + +const SubmitterContext = createContext(null); + +export function useSubmitterContext() { + const context = useContext(SubmitterContext); + if (!context) + throw new Error( + "useSubmitterContext must be used within a SubmitterContextProvider" + ); + return context; +} + +export function SubmitterContextProvider({ + children, + ...submitter +}: ReturnType & { children: ReactNode }) { + return ; +} const isAnyZodObject = (schema: RTFSupportedZodTypes): schema is AnyZodObject => schema._def.typeName === ZodFirstPartyTypeKind.ZodObject; diff --git a/src/index.ts b/src/index.ts index b74bd7f..0614306 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { createUniqueFieldSchema } from "./createFieldSchema"; -export { createTsForm } from "./createSchemaForm"; +export { createTsForm, createTsFormAndFragment } from "./createSchemaForm"; export { useDescription, useReqDescription, @@ -9,5 +9,6 @@ export { useStringFieldInfo, useNumberFieldInfo, useDateFieldInfo, + useMaybeFieldName, } from "./FieldContext"; export type { RTFSupportedZodTypes } from "./supportedZodTypes"; diff --git a/src/isZodTypeEqual.tsx b/src/isZodTypeEqual.tsx index e0fd73d..124a706 100644 --- a/src/isZodTypeEqual.tsx +++ b/src/isZodTypeEqual.tsx @@ -12,9 +12,10 @@ import { import { RTFSupportedZodTypes } from "./supportedZodTypes"; import { unwrap } from "./unwrap"; -export function isZodTypeEqual( +export function isZodTypeEqualImpl( _a: RTFSupportedZodTypes, - _b: RTFSupportedZodTypes + _b: RTFSupportedZodTypes, + visitedTypes: Set ) { // Recursively check objects // if typeNames are equal Unwrap Appropriate Types: @@ -22,6 +23,9 @@ export function isZodTypeEqual( let { type: a, _rtf_id: idA } = unwrap(_a); let { type: b, _rtf_id: idB } = unwrap(_b); + if (visitedTypes.has(a) && visitedTypes.has(b)) return true; + visitedTypes.add(a); + visitedTypes.add(b); if (idA || idB) { return idA === idB; @@ -35,7 +39,7 @@ export function isZodTypeEqual( a._def.typeName === ZodFirstPartyTypeKind.ZodArray && b._def.typeName === ZodFirstPartyTypeKind.ZodArray ) { - if (isZodTypeEqual(a._def.type, b._def.type)) return true; + if (isZodTypeEqualImpl(a._def.type, b._def.type, visitedTypes)) return true; return false; } @@ -45,7 +49,8 @@ export function isZodTypeEqual( a._def.typeName === ZodFirstPartyTypeKind.ZodSet && b._def.typeName === ZodFirstPartyTypeKind.ZodSet ) { - if (isZodTypeEqual(a._def.valueType, b._def.valueType)) return true; + if (isZodTypeEqualImpl(a._def.valueType, b._def.valueType, visitedTypes)) + return true; return false; } @@ -56,8 +61,8 @@ export function isZodTypeEqual( b._def.typeName === ZodFirstPartyTypeKind.ZodMap ) { if ( - isZodTypeEqual(a._def.keyType, b._def.keyType) && - isZodTypeEqual(a._def.valueType, b._def.valueType) + isZodTypeEqualImpl(a._def.keyType, b._def.keyType, visitedTypes) && + isZodTypeEqualImpl(a._def.valueType, b._def.valueType, visitedTypes) ) return true; @@ -69,7 +74,8 @@ export function isZodTypeEqual( a._def.typeName === ZodFirstPartyTypeKind.ZodRecord && b._def.typeName === ZodFirstPartyTypeKind.ZodRecord ) { - if (isZodTypeEqual(a._def.valueType, b._def.valueType)) return true; + if (isZodTypeEqualImpl(a._def.valueType, b._def.valueType, visitedTypes)) + return true; return false; } @@ -82,7 +88,7 @@ export function isZodTypeEqual( const itemsB = b._def.items; if (itemsA.length !== itemsB.length) return false; for (let i = 0; i < itemsA.length; i++) { - if (!isZodTypeEqual(itemsA[i], itemsB[i])) return false; + if (!isZodTypeEqualImpl(itemsA[i], itemsB[i], visitedTypes)) return false; } return true; } @@ -114,12 +120,19 @@ export function isZodTypeEqual( for (var key of keysA) { const valA = shapeA[key]; const valB = shapeB[key]; - if (!valB || !isZodTypeEqual(valA, valB)) return false; + if (!valB || !isZodTypeEqualImpl(valA, valB, visitedTypes)) return false; } } return true; } +export function isZodTypeEqual( + _a: RTFSupportedZodTypes, + _b: RTFSupportedZodTypes +) { + return isZodTypeEqualImpl(_a, _b, new Set()); +} + // Guards export function isZodString( diff --git a/src/supportedZodTypes.ts b/src/supportedZodTypes.ts index 59f99de..df69c4b 100644 --- a/src/supportedZodTypes.ts +++ b/src/supportedZodTypes.ts @@ -15,6 +15,7 @@ import { ZodString, ZodTuple, ZodEffects, + ZodLazy, } from "zod"; /** @@ -39,4 +40,5 @@ export type RTFBaseZodType = export type RTFSupportedZodTypes = | RTFBaseZodType | ZodOptional - | ZodNullable; + | ZodNullable + | ZodLazy; diff --git a/src/unwrap.tsx b/src/unwrap.tsx index efce33e..1589b5a 100644 --- a/src/unwrap.tsx +++ b/src/unwrap.tsx @@ -12,18 +12,30 @@ import { } from "./createFieldSchema"; import { RTFSupportedZodTypes } from "./supportedZodTypes"; -const unwrappable = new Set([ +const unwrappableTypes = [ z.ZodFirstPartyTypeKind.ZodOptional, z.ZodFirstPartyTypeKind.ZodNullable, z.ZodFirstPartyTypeKind.ZodBranded, z.ZodFirstPartyTypeKind.ZodDefault, -]); + z.ZodFirstPartyTypeKind.ZodLazy, +] as const; +const unwrappable = new Set(unwrappableTypes); export type UnwrappedRTFSupportedZodTypes = { type: RTFSupportedZodTypes; [HIDDEN_ID_PROPERTY]: string | null; }; +export function assertNever(x: never): never { + throw new Error("[assertNever] Unexpected value: " + x); +} + +type UnwrappableType = (typeof unwrappableTypes)[number]; + +function isUnwrappable(type: ZodFirstPartyTypeKind): type is UnwrappableType { + return unwrappable.has(type as UnwrappableType); +} + export function unwrap( type: RTFSupportedZodTypes ): UnwrappedRTFSupportedZodTypes { @@ -32,7 +44,7 @@ export function unwrap( let r = type; let unwrappedHiddenId: null | string = null; - while (unwrappable.has(r._def.typeName)) { + while (isUnwrappable(r._def.typeName)) { if (isSchemaWithHiddenProperties(r)) { unwrappedHiddenId = r._def[HIDDEN_ID_PROPERTY]; } @@ -51,6 +63,12 @@ export function unwrap( // @ts-ignore r = r._def.innerType; break; + case z.ZodFirstPartyTypeKind.ZodLazy: + // @ts-ignore + r = r._def.getter(); + break; + default: + assertNever(r._def.typeName); } } diff --git a/tsconfig.json b/tsconfig.json index ad283c6..e4f2739 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, + // uncomment to see full errors! + // "noErrorTruncation": true, "jsx": "react" }, "types": ["node", "jest", "@testing-library/jest-dom"],