diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 1037a54..d2ae9db 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -172,6 +172,7 @@ describe("createSchemaForm", () => { expect(() => render(
{}} schema={Schema} props={{ //@ts-ignore @@ -1354,4 +1355,242 @@ describe("createSchemaForm", () => { screen.queryByText(testData.arrayTextField.label) ).toBeInTheDocument(); }); + it("should render the correct components for a nested object schema if unmaped", 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 BooleanField({}: { c: boolean }) { + return
boolean
; + } + + const objectSchema = z.object({ + text: z.string(), + age: NumberSchema, + }); + const objectSchema2 = z.object({ + bool: z.boolean(), + }); + + const mapping = [ + [z.string(), TextField], + [NumberSchema, NumberField], + [z.boolean(), BooleanField], + [objectSchema2, BooleanField], + ] as const; + + const Form = createTsForm(mapping); + + const schema = z.object({ + nestedField: objectSchema, + nestedField2: objectSchema2, + }); + const defaultValues = { + nestedField: { text: "name", age: 9 }, + nestedField2: { bool: true }, + }; + // TODO: test validation + render( + } + /> + ); + 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); + }); + 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}
; + } + + const otherObjSchema = z.object({ + text: z.string().optional(), + }); + const mapping = [ + [z.string(), TextField], + [NumberSchema, NumberField], + [otherObjSchema, ObjectField], + ] as const; + + const Form = createTsForm(mapping); + + const schema = z.object({ + arrayField: z + .object({ + text: z.string(), + age: NumberSchema, + otherObj: otherObjSchema.optional(), + }) + .array(), + }); + const defaultValues = { + arrayField: [ + { text: "name", age: 9 }, + { text: "name2", age: 10 }, + ], + }; + render( + { + return ; + }} + > + {(renderedFields) => { + return ( + <> + {renderedFields.arrayField.map( + ({ text, age }: any, i: number) => ( + + {text} + {age} + + ) + )} + + ); + }} + + ); + + const textNodes = screen.queryAllByText("text"); + textNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(textNodes).toHaveLength(2); + + const numberNodes = screen.queryAllByText("number"); + numberNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(numberNodes).toHaveLength(2); + + const button = screen.getByText("submit"); + await userEvent.click(button); + expect(mockOnSubmit).toHaveBeenCalledWith(defaultValues); + }); + + it("should render an array component despite recusions", async () => { + const mockOnSubmit = jest.fn(() => {}); + function DynamicArray() { + const { + field: { value, onChange }, + } = useTsController(); + + return ( +
+ + {value?.map((val, i) => { + return ( + + onChange(value?.map((v, j) => (i === j ? e.target.value : v))) + } + /> + ); + })} +
+ ); + } + + function NumberField() { + return
number
; + } + + const mapping = [ + [z.string().array(), DynamicArray], + [z.number(), NumberField], + ] as const; + + const Form = createTsForm(mapping); + + const schema = z.object({ + arrayField: z.string().array(), + numberArray: z.number().array(), + }); + const defaultValues = { + arrayField: ["name", "name2"], + numberArray: [1, 2, 3], + }; + render( +
{ + return ; + }} + >
+ ); + + const numberNodes = screen.queryAllByText("number"); + numberNodes.forEach((node) => expect(node).toBeInTheDocument()); + expect(numberNodes).toHaveLength(3); + + expect(screen.getByTestId("dynamic-array")).toBeInTheDocument(); + const addElementButton = screen.getByTestId("add-element"); + await userEvent.click(addElementButton); + + const inputs = screen.getAllByTestId(/dynamic-array-input/); + expect(inputs.length).toBe(3); + + const input3 = screen.getByTestId("dynamic-array-input2"); + await userEvent.type(input3, "name3"); + const button = screen.getByText("submit"); + await userEvent.click(button); + expect(mockOnSubmit).toHaveBeenCalledWith({ + arrayField: ["name", "name2", "name3"], + numberArray: [1, 2, 3], + }); + }); }); diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 535680a..e6d0402 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -15,7 +15,13 @@ import { useForm, UseFormReturn, } from "react-hook-form"; -import { AnyZodObject, z, ZodEffects } from "zod"; +import { + AnyZodObject, + z, + ZodArray, + ZodEffects, + ZodFirstPartyTypeKind, +} from "zod"; import { getComponentForZodType } from "./getComponentForZodType"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -79,7 +85,7 @@ export function useFormResultValueChangedErrorMesssage() { /** * @internal */ -type FormComponent = "form" | ((props: any) => JSX.Element); +export type FormComponent = "form" | ((props: any) => JSX.Element); export type ExtraProps = { /** @@ -95,14 +101,15 @@ export type ExtraProps = { /** * @internal */ -type UnwrapEffects> = - T extends AnyZodObject - ? T - : T extends ZodEffects - ? EffectsSchema extends ZodEffects - ? EffectsSchemaInner - : EffectsSchema - : never; +export type UnwrapEffects< + T extends RTFSupportedZodTypes | ZodEffects +> = T extends AnyZodObject + ? T + : T extends ZodEffects + ? EffectsSchema extends ZodEffects + ? EffectsSchemaInner + : EffectsSchema + : never; function checkForDuplicateTypes(array: RTFSupportedZodTypes[]) { var combinations = array.flatMap((v, i) => @@ -142,10 +149,175 @@ function propsMapToObect(propsMap: PropsMapping) { return r; } -type RTFFormSchemaType = z.AnyZodObject | ZodEffects; -type RTFFormSubmitFn = ( +export type RTFFormSchemaType = z.AnyZodObject | ZodEffects; +export type RTFFormSubmitFn = ( values: z.infer ) => void | Promise; +export type SchemaShape< + SchemaType extends RTFSupportedZodTypes | ZodEffects +> = ReturnType["_def"]["shape"]>; + +export type IndexOfSchemaInMapping< + Mapping extends FormComponentMapping, + SchemaType extends RTFSupportedZodTypes | ZodEffects, + key extends keyof z.infer> +> = IndexOf< + UnwrapMapping, + readonly [IndexOfUnwrapZodType[key]>, any] +>; + +export type GetTupleFromMapping< + Mapping extends FormComponentMapping, + SchemaType extends RTFSupportedZodTypes | ZodEffects, + key extends keyof z.infer> +> = IndexOfSchemaInMapping extends never + ? never + : Mapping[IndexOfSchemaInMapping]; + +export type Prev = [never, 0, 1, 2, 3]; +export type MaxDefaultRecursionDepth = 1; +export type PropType< + Mapping extends FormComponentMapping, + SchemaType extends RTFSupportedZodTypes | ZodEffects, + PropsMapType extends PropsMapping = typeof defaultPropsMap, + // this controls the depth we allow TS to go into the schema. 2 is enough for most cases, but we could consider exposing this as a generic to allow users to control the depth + Level extends Prev[number] = MaxDefaultRecursionDepth +> = [Level] extends [never] + ? never + : RequireKeysWithRequiredChildren< + Partial<{ + [key in keyof z.infer>]: GetTupleFromMapping< + Mapping, + SchemaType, + key + > extends never + ? UnwrapEffects["shape"][key] extends z.AnyZodObject + ? PropType< + Mapping, + UnwrapEffects["shape"][key], + PropsMapType, + Prev[Level] + > + : UnwrapEffects["shape"][key] extends z.ZodArray + ? PropType< + Mapping, + UnwrapEffects["shape"][key]["element"], + PropsMapType, + Prev[Level] + > + : never + : GetTupleFromMapping extends readonly [ + any, + any + ] // I guess this tells typescript it has a second element? errors without this check. + ? Omit< + ComponentProps[1]>, + PropsMapType[number][1] + > & + ExtraProps + : never; + }> + >; + +export type RenderedFieldMap< + SchemaType extends AnyZodObject | ZodEffects, + Level extends Prev[number] = MaxDefaultRecursionDepth +> = [Level] extends [never] + ? never + : { + [key in keyof z.infer< + UnwrapEffects + >]: UnwrapEffects["shape"][key] extends z.AnyZodObject + ? RenderedFieldMap["shape"][key], Prev[Level]> + : UnwrapEffects["shape"][key] extends z.ZodArray + ? UnwrapEffects["shape"][key]["element"] extends z.AnyZodObject + ? RenderedFieldMap< + UnwrapEffects["shape"][key]["element"], + Prev[Level] + >[] + : JSX.Element[] + : JSX.Element; + }; + +export type RTFFormProps< + Mapping extends FormComponentMapping, + 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` + */ + schema: SchemaType; + /** + * 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. + * @example + * ```tsx + *
} + * /> + * ``` + */ + renderAfter?: (vars: { submit: () => void }) => ReactNode; + /** + * A function that renders components before the form, the function is passed a `submit` function that can be used to trigger + * form submission. + * @example + * ```tsx + * } + * /> + * ``` + */ + renderBefore?: (vars: { submit: () => void }) => ReactNode; + /** + * Use this if you need access to the `react-hook-form` useForm() in the component containing the form component (if you need access to any of its other properties.) + * This will give you full control over you form state (in case you need check if it's dirty or reset it or anything.) + * @example + * ```tsx + * function Component() { + * const form = useForm(); + * return + * } + * ``` + */ + form?: UseFormReturn>; + children?: FunctionComponent>; +} & RequireKeysWithRequiredChildren<{ + /** + * Props to pass to the individual form components. The keys of `props` will be the names of your form properties in the form schema, and they will + * be typesafe to the form components in the mapping passed to `createTsForm`. If any of the rendered form components have required props, this is required. + * @example + * ```tsx + * + * ``` + */ + props?: PropType; +}> & + RequireKeysWithRequiredChildren<{ + /** + * Props to pass to the form container component (by default the props that "form" tags accept) + */ + formProps?: Omit, "children" | "onSubmit">; + }>; /** * Creates a reusable, typesafe form component based on a zod-component mapping. @@ -223,7 +395,9 @@ export function createTsForm< */ propsMap?: PropsMapType; } -) { +): ( + props: RTFFormProps +) => React.ReactElement { const ActualFormComponent = options?.FormComponent ? options.FormComponent : "form"; @@ -243,112 +417,7 @@ export function createTsForm< renderBefore, form, children: CustomChildrenComponent, - }: { - /** - * A Zod Schema - An input field will be rendered for each property in the schema, based on the mapping passed to `createTsForm` - */ - schema: SchemaType; - /** - * 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. - * @example - * ```tsx - * } - * /> - * ``` - */ - renderAfter?: (vars: { submit: () => void }) => ReactNode; - /** - * A function that renders components before the form, the function is passed a `submit` function that can be used to trigger - * form submission. - * @example - * ```tsx - * } - * /> - * ``` - */ - renderBefore?: (vars: { submit: () => void }) => ReactNode; - /** - * Use this if you need access to the `react-hook-form` useForm() in the component containing the form component (if you need access to any of its other properties.) - * This will give you full control over you form state (in case you need check if it's dirty or reset it or anything.) - * @example - * ```tsx - * function Component() { - * const form = useForm(); - * return - * } - * ``` - */ - form?: UseFormReturn>; - children?: FunctionComponent<{ - [key in keyof z.infer>]: ReactNode; - }>; - } & RequireKeysWithRequiredChildren<{ - /** - * Props to pass to the individual form components. The keys of `props` will be the names of your form properties in the form schema, and they will - * be typesafe to the form components in the mapping passed to `createTsForm`. If any of the rendered form components have required props, this is required. - * @example - * ```tsx - * - * ``` - */ - props?: RequireKeysWithRequiredChildren< - Partial<{ - [key in keyof z.infer>]: Mapping[IndexOf< - UnwrapMapping, - readonly [ - IndexOfUnwrapZodType< - ReturnType["_def"]["shape"]>[key] - >, - any - ] - >] extends readonly [any, any] // I guess this tells typescript it has a second element? errors without this check. - ? Omit< - ComponentProps< - Mapping[IndexOf< - UnwrapMapping, - readonly [ - IndexOfUnwrapZodType< - ReturnType< - UnwrapEffects["_def"]["shape"] - >[key] - >, - any - ] - >][1] - >, - PropsMapType[number][1] - > & - ExtraProps - : never; - }> - >; - }> & - RequireKeysWithRequiredChildren<{ - /** - * Props to pass to the form container component (by default the props that "form" tags accept) - */ - formProps?: Omit, "children" | "onSubmit">; - }>) { + }: RTFFormProps) { const useFormResultInitialValue = useRef< undefined | ReturnType >(form); @@ -370,72 +439,127 @@ export function createTsForm< form.reset(defaultValues); } }, []); - const { control, handleSubmit, setError } = _form; - const _schema = unwrapEffects(schema); - const shape: Record = _schema._def.shape(); + const { control, handleSubmit, setError, getValues } = _form; const submitter = useSubmitter({ resolver, onSubmit, setError, }); const submitFn = handleSubmit(submitter.submit); - type SchemaKey = keyof z.infer>; - const renderedFields = Object.keys(shape).reduce( - (accum, key: SchemaKey) => { - // we know this is a string but TS thinks it can be number and symbol so just in case stringify - const stringKey = key.toString(); - const type = shape[key] as RTFSupportedZodTypes; - const Component = getComponentForZodType(type, componentMap); - if (!Component) { - throw new Error( - noMatchingSchemaErrorMessage(stringKey, type._def.typeName) + + function renderComponentForSchemaDeep< + NestedSchemaType extends RTFSupportedZodTypes | ZodEffects, + K extends keyof z.infer> + >( + _type: NestedSchemaType, + props: PropType | undefined, + key: K, + prefixedKey: string, + currentValue: any + ): RenderedElement { + const type = unwrapEffects(_type); + const Component = getComponentForZodType(type, componentMap); + if (!Component) { + if (isAnyZodObject(type)) { + const shape: Record = type._def.shape(); + return Object.entries(shape).reduce((accum, [subKey, subType]) => { + accum[subKey] = renderComponentForSchemaDeep( + subType, + props && props[subKey] ? (props[subKey] as any) : undefined, + subKey, + `${prefixedKey}.${subKey}`, + currentValue && currentValue[subKey] + ); + return accum; + }, {} as RenderedObjectElements); + } + if (isZodArray(type)) { + return ((currentValue as Array | undefined | null) ?? []).map( + (item, index) => { + return renderComponentForSchemaDeep( + type.element, + props, + key, + `${prefixedKey}[${index}]`, + item + ); + } ); } - const meta = getMetaInformationForZodType(type); - - const fieldProps = props && props[key] ? (props[key] as any) : {}; - - const { beforeElement, afterElement } = fieldProps; - - const mergedProps = { - ...(propsMap.name && { [propsMap.name]: key }), - ...(propsMap.control && { [propsMap.control]: control }), - ...(propsMap.enumValues && { - [propsMap.enumValues]: meta.enumValues, - }), - ...(propsMap.descriptionLabel && { - [propsMap.descriptionLabel]: meta.description?.label, - }), - ...(propsMap.descriptionPlaceholder && { - [propsMap.descriptionPlaceholder]: meta.description?.placeholder, - }), - ...fieldProps, - }; - const ctxLabel = meta.description?.label; - const ctxPlaceholder = meta.description?.placeholder; - accum[key] = ( - - {beforeElement} - - - - {afterElement} - + throw new Error( + noMatchingSchemaErrorMessage(key.toString(), type._def.typeName) ); - return accum; - }, - {} as Record - ); - const renderedFieldNodes = Object.values(renderedFields); + } + 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 mergedProps = { + ...(propsMap.name && { [propsMap.name]: prefixedKey }), + ...(propsMap.control && { [propsMap.control]: control }), + ...(propsMap.enumValues && { + [propsMap.enumValues]: meta.enumValues, + }), + ...(propsMap.descriptionLabel && { + [propsMap.descriptionLabel]: meta.description?.label, + }), + ...(propsMap.descriptionPlaceholder && { + [propsMap.descriptionPlaceholder]: meta.description?.placeholder, + }), + ...fieldProps, + }; + const ctxLabel = meta.description?.label; + const ctxPlaceholder = meta.description?.placeholder; + + return ( + + {beforeElement} + + + + {afterElement} + + ); + } + function renderFields( + schema: SchemaType, + props: PropType | undefined + ) { + type SchemaKey = keyof z.infer>; + const _schema = unwrapEffects(schema); + const shape: Record = _schema._def.shape(); + return Object.entries(shape).reduce( + (accum, [key, type]: [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] + ); + return accum; + }, + {} as RenderedObjectElements + ) as RenderedFieldMap; + } + + const renderedFields = renderFields(schema, props); + const renderedFieldNodes = flattenRenderedElements(renderedFields); return ( @@ -506,3 +630,28 @@ function useSubmitter({ addToCoerceUndefined, }; } + +const isAnyZodObject = (schema: RTFSupportedZodTypes): schema is AnyZodObject => + schema._def.typeName === ZodFirstPartyTypeKind.ZodObject; +const isZodArray = (schema: RTFSupportedZodTypes): schema is ZodArray => + schema._def.typeName === ZodFirstPartyTypeKind.ZodArray; + +export type RenderedElement = + | JSX.Element + | JSX.Element[] + | RenderedObjectElements + | RenderedElement[]; +export type RenderedObjectElements = { [key: string]: RenderedElement }; + +/*** + * Can be useful in CustomChildComponents to flatten the rendered field map at a given leve + */ +export function flattenRenderedElements(val: RenderedElement): JSX.Element[] { + return Array.isArray(val) + ? val.flatMap((obj) => flattenRenderedElements(obj)) + : typeof val === "object" && val !== null && !React.isValidElement(val) + ? Object.values(val).reduce((accum: JSX.Element[], val) => { + return accum.concat(flattenRenderedElements(val as any)); + }, [] as JSX.Element[]) + : [val]; +}