From 6614d1873fe8fad1e98ad720377686565d2cf599 Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Fri, 14 Jul 2023 17:01:28 -0700 Subject: [PATCH 1/9] expose a FormFragment component for use in complex components --- src/FieldContext.tsx | 7 +- src/__tests__/createSchemaForm.test.tsx | 71 +++++- src/createSchemaForm.tsx | 284 ++++++++++++++++-------- 3 files changed, 272 insertions(+), 90 deletions(-) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 79c7174..432ae95 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) @@ -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 3df890b..e66f5db 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -11,6 +11,7 @@ import { } from "./utils/testForm"; import { createTsForm, + createTsFormAndFragment, noMatchingSchemaErrorMessage, useFormResultValueChangedErrorMesssage, } from "../createSchemaForm"; @@ -1479,7 +1480,7 @@ describe("createSchemaForm", () => { return createUniqueFieldSchema(z.date().min(min).max(max), uniqueId); }, get component() { - const { min,max, label, uniqueId } = this; + const { min, max, label, uniqueId } = this; const ArrayDateFieldComponent = () => { const fieldInfo = useDateFieldInfo(); @@ -1847,4 +1848,72 @@ describe("createSchemaForm", () => { const inputs = screen.getAllByTestId(/dynamic-array-input/); expect(inputs.length).toBe(3); }); + it("should provide a nested renderer for use in complex components", async () => { + const NumberSchema = createUniqueFieldSchema(z.number(), "number"); + const mockOnSubmit = jest.fn(); + + function TextField({}: { b?: "1" }) { + const { error } = useTsController(); + return ( + <> +
text
+
{error?.errorMessage}
+ + ); + } + + function NumberField({}: { a?: 1 }) { + return
number
; + } + + function ComplexField({}: { complexProp1: boolean }) { + return ( +
+ +
+ ); + } + + const objectSchema2 = z.object({ + num: NumberSchema, + str: z.string(), + }); + + const mapping = [ + [z.string(), TextField], + [NumberSchema, NumberField], + [objectSchema2, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField2: objectSchema2, + }); + const defaultValues = { + nestedField2: { num: 4, str: "this" }, + }; + // TODO: test validation + render( +
} + /> + ); + screen.debug(); + const button = screen.getByText("submit"); + await userEvent.click(button); + + const textNodes = screen.queryByText("text"); + expect(textNodes).toBeInTheDocument(); + const numberNodes = screen.queryByText("number"); + expect(numberNodes).toBeInTheDocument(); + expect(screen.queryByTestId("error")).toHaveTextContent(""); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); }); diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 8adbad5..17282fc 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -4,6 +4,8 @@ import React, { FunctionComponent, 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 { @@ -240,24 +243,18 @@ export type RenderedFieldMap< : JSX.Element; }; -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. @@ -294,6 +291,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?: FunctionComponent>; } & RequireKeysWithRequiredChildren<{ /** @@ -312,16 +329,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. @@ -335,7 +433,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" @@ -356,62 +454,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, @@ -443,7 +494,7 @@ export function createTsForm< form.reset(defaultValues); } }, []); - const { control, handleSubmit, setError, getValues } = _form; + const { handleSubmit, setError } = _form; const submitter = useSubmitter({ resolver, onSubmit, @@ -451,6 +502,42 @@ export function createTsForm< }); const submitFn = handleSubmit(submitter.submit); + return ( + + + + renderAfter({ submit: submitFn })), + renderBefore: + renderBefore && (() => renderBefore({ submit: submitFn })), + children: CustomChildrenComponent, + } as any)} + /> + + + + ); + } + + function FormFragment({ + schema, + props, + renderAfter, + renderBefore, + children: CustomChildrenComponent, + }: RTFSharedFormProps & { + renderBefore?: (props: { submit?: () => void }) => ReactNode; + renderAfter?: (props: { submit?: () => void }) => ReactNode; + name?: string; + }) { + const { control, getValues } = useFormContext>(); + + const namePrefix = useMaybeFieldName(); + const submitter = useSubmitterContext(); function renderComponentForSchemaDeep< NestedSchemaType extends RTFSupportedZodTypes | ZodEffects, K extends keyof z.infer> @@ -553,7 +640,7 @@ export function createTsForm< type, props as any, stringKey, - stringKey, + [namePrefix, stringKey].filter(Boolean).join("."), getValues()[key] ); return accum; @@ -565,20 +652,21 @@ export function createTsForm< const renderedFields = renderFields(schema, props); const renderedFieldNodes = flattenRenderedElements(renderedFields); return ( - - - {renderBefore && renderBefore({ submit: submitFn })} - {CustomChildrenComponent ? ( - - ) : ( - renderedFieldNodes - )} - {renderAfter && renderAfter({ submit: submitFn })} - - + <> + {renderBefore && renderBefore({})} + {CustomChildrenComponent ? ( + + ) : ( + renderedFieldNodes + )} + {renderAfter && renderAfter({})} + ); - }; + } + + return [TsForm, FormFragment] 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 @@ -635,6 +723,26 @@ function useSubmitter({ }; } +const SubmitterContext = createContext | null>( + 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; const isZodArray = (schema: RTFSupportedZodTypes): schema is ZodArray => From 0bb22cd2ac24a2f286772f8b135adcc80078d1d3 Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Tue, 18 Jul 2023 10:59:47 -0700 Subject: [PATCH 2/9] add more test cases for FormFragment, support ZodLazy, allow configurable jest timeout, improve test form components --- jest.config.cjs | 3 + src/FieldContext.tsx | 14 +- src/__tests__/createSchemaForm.test.tsx | 521 +++++++++++++++++++++--- src/__tests__/utils/testForm.tsx | 62 ++- src/createSchemaForm.tsx | 3 +- src/isZodTypeEqual.tsx | 31 +- src/supportedZodTypes.ts | 4 +- src/unwrap.tsx | 24 +- 8 files changed, 579 insertions(+), 83 deletions(-) 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 432ae95..3ad61b2 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -104,17 +104,21 @@ function useContextProt(name: string) { * * ) */ -export function useTsController() { +export function useTsController({ + name, +}: { + name?: string; +} = {}) { const context = useContextProt("useTsController"); type IsObj = FieldType extends Object ? true : false; type OnChangeValue = IsObj extends true ? DeepPartial | undefined : FieldType | undefined; // Just gives better types to useController - const controller = useController(context) as any as Omit< - UseControllerReturn, - "field" - > & { + const controller = useController({ + ...context, + name: name ?? context.name, + }) as unknown as Omit & { field: Omit & { value: FieldType | undefined; onChange: (value: OnChangeValue) => void; diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index e66f5db..5190210 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -3,10 +3,17 @@ 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, + TextField, textFieldTestId, } from "./utils/testForm"; import { @@ -19,7 +26,12 @@ import { SPLIT_DESCRIPTION_SYMBOL as DESCRIPTION_SEPARATOR_SYMBOL, SPLIT_DESCRIPTION_SYMBOL, } from "../getMetaInformationForZodType"; -import { Control, useController, useForm } from "react-hook-form"; +import { + Control, + useController, + useFieldArray, + useForm, +} from "react-hook-form"; import userEvent from "@testing-library/user-event"; import { useDescription, @@ -29,6 +41,7 @@ import { useStringFieldInfo, useFieldInfo, useDateFieldInfo, + useMaybeFieldName, } from "../FieldContext"; import { expectTypeOf } from "expect-type"; import { createUniqueFieldSchema } from "../createFieldSchema"; @@ -1590,7 +1603,7 @@ describe("createSchemaForm", () => { 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 () => { @@ -1848,72 +1861,466 @@ describe("createSchemaForm", () => { const inputs = screen.getAllByTestId(/dynamic-array-input/); expect(inputs.length).toBe(3); }); - it("should provide a nested renderer for use in complex components", async () => { - const NumberSchema = createUniqueFieldSchema(z.number(), "number"); - const mockOnSubmit = jest.fn(); + describe("FormFragment", () => { + it("should provide a nested renderer for use in complex components", async () => { + const mockOnSubmit = jest.fn(); + + function ComplexField({}: { complexProp1: boolean }) { + return ( +
+ +
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), + }); - function TextField({}: { b?: "1" }) { - const { error } = useTsController(); - return ( - <> -
text
-
{error?.errorMessage}
- + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [objectSchema, ComplexField], + ] as const; + + const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + 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); + + const textNodes = screen.queryByTestId(defaultTextInputTestId); + expect(textNodes).toBeInTheDocument(); + expect(textNodes).toHaveDisplayValue("this"); + const numberNodes = screen.queryByTestId(defaultNumberInputTestId); + expect(numberNodes).toBeInTheDocument(); + expect(numberNodes).toHaveDisplayValue("4"); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); + //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: boolean }) { + const { + field: { value, onChange }, + } = useTsController>(); + return ( +
+ {value?.map((_val, i) => { + return ( + + ); + })} + + +
+ ); + } + + const objectSchema = z.object({ + num: z.number(), + str: z.string(), + }); - function NumberField({}: { a?: 1 }) { - return
number
; - } + const complexSchema = z.array(objectSchema); + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [complexSchema, ComplexField], + ] as const; - function ComplexField({}: { complexProp1: boolean }) { - return ( -
- -
+ const [Form, FormFragment] = createTsFormAndFragment(mapping); + + const schema = z.object({ + nestedField: complexSchema, + }); + const defaultValues = { + nestedField: [{ num: 4, str: "this" }], + }; + const form = ( + } + /> ); - } + // TODO: test validation + 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()); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + 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"); + }); - const objectSchema2 = z.object({ - num: NumberSchema, - str: z.string(), + 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 ( +
+ {value?.map((_val, i) => { + return ( + + ); + })} + + +
+ ); + } + + 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" }], + }; + const form = ( + } + /> + ); + // TODO: test validation + 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()); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + 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"); }); - const mapping = [ - [z.string(), TextField], - [NumberSchema, NumberField], - [objectSchema2, ComplexField], - ] as const; + it("should be able to split up and reorder complex schemas", async () => { + const mockOnSubmit = jest.fn(); + debugger; + 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 [Form, FormFragment] = createTsFormAndFragment(mapping); + const mapping = [ + [z.string(), TextField], + [z.number(), NumberField], + [z.boolean(), BooleanField], + [objectSchema, ComplexField], + ] as const; - const schema = z.object({ - nestedField2: objectSchema2, + 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"); + const booleanNodes = screen.queryByTestId(defaultBooleanInputTestId); + expect(booleanNodes).toBeInTheDocument(); + expect(booleanNodes).toBeChecked(); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); - const defaultValues = { - nestedField2: { num: 4, str: "this" }, - }; - // TODO: test validation - render( - } - /> - ); - screen.debug(); - const button = screen.getByText("submit"); - await userEvent.click(button); + it("should render recursive object schemas", async () => { + const mockOnSubmit = jest.fn(); + function RecursiveObjectField({ + name, + }: { + complexProp1?: boolean; + name: string; + }) { + const namePrefix = useMaybeFieldName(); + const { + field: { value }, + } = useTsController>({ + name: [namePrefix, name].join(""), + }); + 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(), + }); - const textNodes = screen.queryByText("text"); - expect(textNodes).toBeInTheDocument(); - const numberNodes = screen.queryByText("number"); - expect(numberNodes).toBeInTheDocument(); - expect(screen.queryByTestId("error")).toHaveTextContent(""); - expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + 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] = 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"); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); }); }); diff --git a/src/__tests__/utils/testForm.tsx b/src/__tests__/utils/testForm.tsx index d131ff6..4387a4b 100644 --- a/src/__tests__/utils/testForm.tsx +++ b/src/__tests__/utils/testForm.tsx @@ -3,8 +3,18 @@ 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?.errorMessage}
; +} export function TextField(props: { control: Control; @@ -17,35 +27,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 +106,7 @@ function CustomTextField(props: { return (
+
); } diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 17282fc..8736616 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -529,6 +529,7 @@ export function createTsFormAndFragment< renderAfter, renderBefore, children: CustomChildrenComponent, + name, }: RTFSharedFormProps & { renderBefore?: (props: { submit?: () => void }) => ReactNode; renderAfter?: (props: { submit?: () => void }) => ReactNode; @@ -640,7 +641,7 @@ export function createTsFormAndFragment< type, props as any, stringKey, - [namePrefix, stringKey].filter(Boolean).join("."), + [namePrefix, name, stringKey].filter(Boolean).join("."), getValues()[key] ); return accum; 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); } } From 6c845f8efa256ab3d47b1f7790c9a836edb57056 Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Wed, 19 Jul 2023 15:05:27 -0700 Subject: [PATCH 3/9] use FormFragmentField instead of customizing name to controller, also move render props outside fragment and fix a bug --- src/FieldContext.tsx | 14 +-- src/__tests__/createSchemaForm.test.tsx | 46 ++++---- src/createSchemaForm.tsx | 137 ++++++++++++++++-------- tsconfig.json | 2 + 4 files changed, 120 insertions(+), 79 deletions(-) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 3ad61b2..d5c1956 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -104,21 +104,17 @@ function useContextProt(name: string) { * * ) */ -export function useTsController({ - name, -}: { - name?: string; -} = {}) { +export function useTsController() { const context = useContextProt("useTsController"); type IsObj = FieldType extends Object ? true : false; type OnChangeValue = IsObj extends true ? DeepPartial | undefined : FieldType | undefined; // Just gives better types to useController - const controller = useController({ - ...context, - name: name ?? context.name, - }) as unknown as Omit & { + const controller = useController(context) as unknown as Omit< + UseControllerReturn, + "field" + > & { field: Omit & { value: FieldType | undefined; onChange: (value: OnChangeValue) => void; diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 5190210..05eff6b 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -41,7 +41,6 @@ import { useStringFieldInfo, useFieldInfo, useDateFieldInfo, - useMaybeFieldName, } from "../FieldContext"; import { expectTypeOf } from "expect-type"; import { createUniqueFieldSchema } from "../createFieldSchema"; @@ -1610,14 +1609,6 @@ describe("createSchemaForm", () => { 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}
; } @@ -1654,7 +1645,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 ; }} @@ -1676,11 +1673,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); @@ -1935,7 +1932,7 @@ describe("createSchemaForm", () => {
{value?.map((_val, i) => { return ( - + ); })} } + /> + ); + 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); + screen.debug(); + expect(screen.queryByText("Yay")).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"); + screen + .queryAllByTestId(errorMessageTestId) + .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); //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(); From 4c4754f4e11d525b4ea2b72d63d50f5e0a34b2ed Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Wed, 19 Jul 2023 15:45:08 -0700 Subject: [PATCH 5/9] add test condition to ensure editing actually affects the form values --- src/__tests__/createSchemaForm.test.tsx | 36 ++++++++++++++++++------- src/createSchemaForm.tsx | 2 +- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index e51e2b2..3fe712e 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -1920,9 +1920,9 @@ describe("createSchemaForm", () => { .forEach((node) => expect(node).toBeEmptyDOMElement()); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); - it("should allow deep rendering", async () => { + fit("should allow deep rendering", async () => { const mockOnSubmit = jest.fn(); - + debugger; function ComplexField({}: { complexProp1: boolean }) { const { field: { value }, @@ -1978,22 +1978,38 @@ describe("createSchemaForm", () => { /> ); const { rerender } = render(form); + + expect(screen.queryByText("Yay")).toBeInTheDocument(); + const textNode = screen.queryByTestId(defaultTextInputTestId); + if (!textNode) { + throw new Error("textNode is null"); + } + 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); screen.debug(); - expect(screen.queryByText("Yay")).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"); screen .queryAllByTestId(errorMessageTestId) .forEach((node) => expect(node).toBeEmptyDOMElement()); - expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + 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 () => { diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 9cdb514..fc2b855 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -623,7 +623,7 @@ export function createTsFormAndFragment< } const name = [namePrefix, stringifySchemaKey(schemaKey)] .filter(Boolean) - .join("."); + .join(typeof schemaKey === "number" ? "" : "."); return renderComponentForSchemaDeep( schema, props as any, From 571a60cce489f198a90d195ff5c0acc14a0349be Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Mon, 31 Jul 2023 15:05:05 -1000 Subject: [PATCH 6/9] use the passed complex props and add one case of exposing the proptype from a mapped component --- src/__tests__/createSchemaForm.test.tsx | 43 +++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 3fe712e..31aac72 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -17,6 +17,7 @@ import { textFieldTestId, } from "./utils/testForm"; import { + PropType, createTsForm, createTsFormAndFragment, noMatchingSchemaErrorMessage, @@ -1335,8 +1336,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); @@ -1582,7 +1582,7 @@ describe("createSchemaForm", () => { nestedField: { text: "name", age: 9 }, nestedField2: { bool: true }, }; - // TODO: test validation + render( { it("should provide a nested renderer for use in complex components", async () => { const mockOnSubmit = jest.fn(); - function ComplexField({}: { complexProp1: boolean }) { + function ComplexField({ complexProp1 }: { complexProp1: boolean }) { return (
+ {complexProp1 &&
complexProp1
} { 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"); @@ -1920,15 +1921,16 @@ describe("createSchemaForm", () => { .forEach((node) => expect(node).toBeEmptyDOMElement()); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); - fit("should allow deep rendering", async () => { + it("should allow deep rendering", async () => { const mockOnSubmit = jest.fn(); debugger; - function ComplexField({}: { complexProp1: boolean }) { + function ComplexField({ complexProp1 }: { complexProp1: boolean }) { const { field: { value }, } = useTsController>(); return (
+ {complexProp1 &&
complexProp1
}
{value?.displayValue}
{ 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"); @@ -2015,15 +2018,27 @@ describe("createSchemaForm", () => { it("should render dynamic arrays", async () => { const mockOnSubmit = jest.fn(); const addedValue = { num: 3, str: "this2" }; - function ComplexField({}: { complexProp1: boolean }) { + function ComplexField({ + complexProp1, + ...restProps + }: { complexProp1: boolean } & PropType< + typeof mapping, + typeof objectSchema + >) { const { field: { value, onChange }, } = useTsController>(); return (
+ {complexProp1 &&
complexProp1
} {value?.map((_val, i) => { return ( - + ); })} } /> ); - // TODO: test validation const { rerender } = render(form); await userEvent.click(screen.getByText("Add item")); await userEvent.click(screen.getByText("submit")); @@ -2085,8 +2099,10 @@ describe("createSchemaForm", () => { const basicNodes = [ ...screen.queryAllByTestId(defaultTextInputTestId), ...screen.queryAllByTestId(defaultNumberInputTestId), + screen.queryByText("complexProp1"), + ...screen.queryAllByText("%"), ]; - expect(basicNodes).toHaveLength(4); + expect(basicNodes).toHaveLength(7); basicNodes.forEach((node) => expect(node).toBeInTheDocument()); expect(basicNodes[0]).toHaveDisplayValue("this"); expect(basicNodes[2]).toHaveDisplayValue("4"); @@ -2171,7 +2187,7 @@ describe("createSchemaForm", () => { renderAfter={() => } /> ); - // TODO: test validation + const { rerender } = render(form); await userEvent.click(screen.getByText("Add item")); await userEvent.click(screen.getByText("submit")); @@ -2271,6 +2287,7 @@ describe("createSchemaForm", () => { 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(); From 88067f15d2deed0222a6d6228e6314b1a37ca9e5 Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Mon, 31 Jul 2023 15:08:32 -1000 Subject: [PATCH 7/9] early null for no error and remove debug --- src/__tests__/createSchemaForm.test.tsx | 27 ++++++------------------- src/__tests__/utils/testForm.tsx | 4 +++- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 31aac72..3c3f815 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -1916,14 +1916,11 @@ describe("createSchemaForm", () => { const numberNodes = screen.queryByTestId(defaultNumberInputTestId); expect(numberNodes).toBeInTheDocument(); expect(numberNodes).toHaveDisplayValue("4"); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); it("should allow deep rendering", async () => { const mockOnSubmit = jest.fn(); - debugger; function ComplexField({ complexProp1 }: { complexProp1: boolean }) { const { field: { value }, @@ -1999,10 +1996,7 @@ describe("createSchemaForm", () => { await userEvent.click(button); // this rerender is currently needed because setError seemingly doesn't rerender the component using useController rerender(form); - screen.debug(); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith({ ...defaultValues, nestedField: { @@ -2108,9 +2102,7 @@ describe("createSchemaForm", () => { expect(basicNodes[2]).toHaveDisplayValue("4"); expect(basicNodes[1]).toHaveDisplayValue(addedValue.str); expect(basicNodes[3]).toHaveDisplayValue(addedValue.num.toString()); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith({ ...defaultValues, nestedField: [...defaultValues.nestedField, addedValue], @@ -2203,9 +2195,7 @@ describe("createSchemaForm", () => { expect(basicNodes[2]).toHaveDisplayValue("4"); expect(basicNodes[1]).toHaveDisplayValue(addedValue.str); expect(basicNodes[3]).toHaveDisplayValue(addedValue.num.toString()); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith({ ...defaultValues, nestedField: [...defaultValues.nestedField, addedValue], @@ -2223,7 +2213,6 @@ describe("createSchemaForm", () => { it("should be able to split up and reorder complex schemas", async () => { const mockOnSubmit = jest.fn(); - debugger; function ComplexField({}: { complexProp1: boolean }) { return (
@@ -2291,9 +2280,7 @@ describe("createSchemaForm", () => { const booleanNodes = screen.queryByTestId(defaultBooleanInputTestId); expect(booleanNodes).toBeInTheDocument(); expect(booleanNodes).toBeChecked(); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); it("should render recursive object schemas", async () => { @@ -2419,9 +2406,7 @@ describe("createSchemaForm", () => { const numberNodes = screen.queryByTestId(defaultNumberInputTestId); expect(numberNodes).toBeInTheDocument(); expect(numberNodes).toHaveDisplayValue("4"); - screen - .queryAllByTestId(errorMessageTestId) - .forEach((node) => expect(node).toBeEmptyDOMElement()); + expect(screen.queryAllByTestId(errorMessageTestId)).toHaveLength(0); expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); }); }); diff --git a/src/__tests__/utils/testForm.tsx b/src/__tests__/utils/testForm.tsx index 4387a4b..fca2e48 100644 --- a/src/__tests__/utils/testForm.tsx +++ b/src/__tests__/utils/testForm.tsx @@ -13,7 +13,9 @@ export const errorMessageTestId = "error-message"; export function ErrorMessage() { const { error } = useTsController(); - return
{error?.errorMessage}
; + return !error ? null : ( +
{error?.errorMessage}
+ ); } export function TextField(props: { From 9f8f194467a03cc0b5e09c331bd87e68347b2cfd Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Tue, 1 Aug 2023 09:27:49 -1000 Subject: [PATCH 8/9] export the new function --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b74bd7f..dd6318c 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, From 55cb810fe32972b379655cda90b68e78eba06c50 Mon Sep 17 00:00:00 2001 From: Sterling Camden Date: Wed, 2 Aug 2023 12:56:44 -1000 Subject: [PATCH 9/9] export useMaybeFieldName as well --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index dd6318c..0614306 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,6 @@ export { useStringFieldInfo, useNumberFieldInfo, useDateFieldInfo, + useMaybeFieldName, } from "./FieldContext"; export type { RTFSupportedZodTypes } from "./supportedZodTypes";