From 0591dff533999d498b689643f17920583e58e16b Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Wed, 22 Mar 2023 19:40:15 -0500 Subject: [PATCH 1/6] add schema-aware field hooks --- package-lock.json | 28 +++- package.json | 3 +- pnpm-lock.yaml | 8 +- src/FieldContext.tsx | 146 +++++++++++++++++++++ src/__tests__/createSchemaForm.test.tsx | 157 +++++++++++++++++++++++ src/createSchemaForm.tsx | 3 + src/index.ts | 3 + src/isZodTypeEqual.tsx | 71 +++++++++- src/typeUtilities.ts | 21 +++ src/unwrap.tsx | 8 +- src/utilities.ts | 50 ++++++++ www/docs/docs/usage/field-schema-info.md | 76 +++++++++++ www/package-lock.json | 5 + www/sidebars.js | 1 + 14 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 src/utilities.ts create mode 100644 www/docs/docs/usage/field-schema-info.md diff --git a/package-lock.json b/package-lock.json index cffa4cb..ff61351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ts-react/form", - "version": "1.0.6", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ts-react/form", - "version": "1.0.6", + "version": "1.4.5", "license": "ISC", "devDependencies": { "@hookform/resolvers": "^2.9.10", @@ -17,9 +17,12 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.2.4", "@types/react": "^18.0.26", + "@types/testing-library__jest-dom": "^5.14.5", + "@types/yargs": "^17.0.23", "expect-type": "^0.15.0", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", + "prettier": "^2.8.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.41.3", @@ -1467,9 +1470,9 @@ "dev": true }, "node_modules/@types/yargs": { - "version": "17.0.22", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz", - "integrity": "sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==", + "version": "17.0.23", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.23.tgz", + "integrity": "sha512-yuogunc04OnzGQCrfHx+Kk883Q4X0aSwmYZhKjI21m+SVYzjIbrWl8dOOwSv5hf2Um2pdCOXWo9isteZTNXUZQ==", "dev": true, "dependencies": { "@types/yargs-parser": "*" @@ -4617,6 +4620,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.6.tgz", + "integrity": "sha512-mtuzdiBbHwPEgl7NxWlqOkithPyp4VN93V7VeHVWBF+ad3I5avc0RVDT4oImXQy9H/AqxA2NSQH8pSxHW6FYbQ==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index 49bee5b..7d01095 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ts-react/form", - "version": "1.0.6", + "version": "1.4.5", "description": "Build forms faster!", "module": "lib/index.mjs", "main": "lib/index.cjs", @@ -35,6 +35,7 @@ "@types/jest": "^29.2.4", "@types/react": "^18.0.26", "@types/testing-library__jest-dom": "^5.14.5", + "@types/yargs": "^17.0.23", "expect-type": "^0.15.0", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed3fd0..d98b5d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ specifiers: '@types/jest': ^29.2.4 '@types/react': ^18.0.26 '@types/testing-library__jest-dom': ^5.14.5 + '@types/yargs': ^17.0.23 expect-type: ^0.15.0 jest: ^29.3.1 jest-environment-jsdom: ^29.3.1 @@ -32,6 +33,7 @@ devDependencies: '@types/jest': 29.4.0 '@types/react': 18.0.28 '@types/testing-library__jest-dom': 5.14.5 + '@types/yargs': 17.0.23 expect-type: 0.15.0 jest: 29.5.0 jest-environment-jsdom: 29.5.0 @@ -631,7 +633,7 @@ packages: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 '@types/node': 18.14.6 - '@types/yargs': 17.0.22 + '@types/yargs': 17.0.23 chalk: 4.1.2 dev: true @@ -920,8 +922,8 @@ packages: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true - /@types/yargs/17.0.22: - resolution: {integrity: sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g==} + /@types/yargs/17.0.23: + resolution: {integrity: sha512-yuogunc04OnzGQCrfHx+Kk883Q4X0aSwmYZhKjI21m+SVYzjIbrWl8dOOwSv5hf2Um2pdCOXWo9isteZTNXUZQ==} dependencies: '@types/yargs-parser': 21.0.0 dev: true diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 624bb3a..7fbcdce 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -8,6 +8,18 @@ import { } from "react-hook-form"; import { printUseEnumWarning } from "./logging"; import { errorFromRhfErrorObject } from "./zodObjectErrors"; +import { RTFSupportedZodTypes } from "./supportedZodTypes"; +import { UnwrapZodType, unwrap } from "./unwrap"; +import { + RTFSupportedZodFirstPartyTypeKind, + RTFSupportedZodFirstPartyTypeKindMap, + isTypeOf, +} from "./isZodTypeEqual"; + +import { + PickPrimitiveObjectProperties, + pickPrimitiveObjectProperties, +} from "./utilities"; export const FieldContext = createContext; @@ -15,6 +27,7 @@ export const FieldContext = createContext void; removeFromCoerceUndefined: (v: string) => void; }>(null); @@ -26,6 +39,7 @@ export function FieldContextProvider({ label, placeholder, enumValues, + zodType, addToCoerceUndefined, removeFromCoerceUndefined, }: { @@ -35,6 +49,7 @@ export function FieldContextProvider({ placeholder?: string; enumValues?: string[]; children: ReactNode; + zodType: RTFSupportedZodTypes; addToCoerceUndefined: (v: string) => void; removeFromCoerceUndefined: (v: string) => void; }) { @@ -46,6 +61,7 @@ export function FieldContextProvider({ label, placeholder, enumValues, + zodType, addToCoerceUndefined, removeFromCoerceUndefined, }} @@ -201,6 +217,13 @@ export function enumValuesNotPassedError() { return `Enum values not passed. Any component that calls useEnumValues should be rendered from an '.enum()' zod field.`; } +export function fieldSchemaMismatchHookError( + hookName: string, + expectedType: string +) { + return `Make sure that ${hookName} hook it is being called inside of a custom component which matches the type '${expectedType}'`; +} + /** * Gets an enum fields values. Throws an error if there are no enum values found (IE you mapped a z.string() to a component * that calls this hook). @@ -228,3 +251,126 @@ export function useEnumValues() { if (!enumValues) throw new Error(enumValuesNotPassedError()); return enumValues; } + +/** + * Returns the Zod type for a field. + * + * @returns The Zod type for the field. + */ +export function useFieldZodType() { + const { zodType } = useContextProt("useFieldZodType"); + return zodType; +} + +function getFieldInfo< + TZodType extends RTFSupportedZodTypes, + TUnwrapZodType extends UnwrapZodType = UnwrapZodType +>(zodType: TZodType) { + const { type, _rtf_id } = unwrap(zodType); + + return { + type: type as TUnwrapZodType, + zodType, + uniqueId: _rtf_id ?? undefined, + isOptional: zodType.isOptional(), + isNullable: zodType.isNullable(), + }; +} + +export function internal_useFieldInfo< + TZodType extends RTFSupportedZodTypes = RTFSupportedZodTypes, + TUnwrappedZodType extends UnwrapZodType = UnwrapZodType +>(hookName: string) { + const { zodType, label, placeholder } = useContextProt(hookName); + + const fieldInfo = getFieldInfo( + zodType as TZodType + ); + + return { ...fieldInfo, label, placeholder }; +} + +/** + * Returns schema-related information for a field + * + * @returns The Zod type for the field. + */ +export function useFieldInfo() { + return internal_useFieldInfo("useFieldInfo"); +} + +export function usePickZodFields< + TZodKindName extends RTFSupportedZodFirstPartyTypeKind, + TZodType extends RTFSupportedZodFirstPartyTypeKindMap[TZodKindName] = RTFSupportedZodFirstPartyTypeKindMap[TZodKindName], + TUnwrappedZodType extends UnwrapZodType = UnwrapZodType, + TPick extends Partial< + PickPrimitiveObjectProperties + > = Partial> +>(zodKindName: TZodKindName, pick: TPick, hookName: string) { + const fieldInfo = internal_useFieldInfo( + hookName + ); + const { type } = fieldInfo; + + if (!isTypeOf(type, zodKindName)) { + throw new Error(fieldSchemaMismatchHookError(hookName, zodKindName)); + } + + return { + ...pickPrimitiveObjectProperties(type, pick), + ...fieldInfo, + }; +} + +/** + * Returns schema-related information for a ZodString field + * + * @example + * ```tsx + * const CustomComponent = () => { + * const { minLength, maxLength, uniqueId } = useStringFieldInfo(); + * + * return ; + * }; + * ``` + * @returns Information for a ZodString field + */ +export function useStringFieldInfo() { + return usePickZodFields( + "ZodString", + { + isCUID: true, + isCUID2: true, + isDatetime: true, + isEmail: true, + isEmoji: true, + isIP: true, + isULID: true, + isURL: true, + isUUID: true, + maxLength: true, + minLength: true, + }, + "useStringFieldInfo" + ); +} + +/** + * Returns schema-related information for a ZodNumber field + * + * @returns data for a ZodNumber field + */ +export function useNumberFieldInfo() { + return usePickZodFields( + "ZodNumber", + { + isFinite: true, + isInt: true, + maxValue: true, + minValue: true, + }, + "useNumberFieldInfo" + ); +} + +const {} = useNumberFieldInfo; diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 2db2318..43f6d6b 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -25,9 +25,13 @@ import { useEnumValues, useReqDescription, useTsController, + useFieldZodType, + useStringFieldInfo, + useFieldInfo, } from "../FieldContext"; import { expectTypeOf } from "expect-type"; import { createUniqueFieldSchema } from "../createFieldSchema"; +import { unwrap } from "../unwrap"; const testIds = { textField: "_text-field", @@ -1181,4 +1185,157 @@ describe("createSchemaForm", () => { expect(screen.queryByText("one")).toBeInTheDocument(); expect(screen.queryByText("two")).toBeInTheDocument(); }); + it("should be possible to get the Zod type for a field using `useFieldZodType`", () => { + const stringComponentId = "custom-string"; + const numberComponentId = "custom-number"; + + const StringSchema = createUniqueFieldSchema(z.string(), stringComponentId); + const NumberSchema = createUniqueFieldSchema(z.number(), numberComponentId); + + const schema = z.object({ + name: StringSchema.optional(), + age: NumberSchema, + }); + + const mapping = [ + [StringSchema, TextField], + [z.string(), TextField], + [NumberSchema, NumberField], + ] as const; + + const Form = createTsForm(mapping); + + function TextField() { + const zodType = useFieldZodType(); + + expect(unwrap(zodType)._rtf_id).toBe(stringComponentId); + + return ; + } + + function NumberField() { + const zodType = useFieldZodType(); + + expect(unwrap(zodType)._rtf_id).toBe(numberComponentId); + + return ; + } + + render(
{}} />); + }); + it.only("should be possible to get ZodAny information using `useFieldInfo`", () => { + const testData = { + requiredTextField: { + label: "required-label", + placeholder: "required-placeholder", + uniqueId: "required-text-field", + }, + optionalTextField: { + label: "optional-label", + placeholder: "optional-placeholder", + uniqueId: "optional-text-field", + }, + }; + + const description = (k: keyof typeof testData) => + `${testData[k].label}${DESCRIPTION_SEPARATOR_SYMBOL}${testData[k].placeholder}`; + + const RequiredTextFieldSchema = createUniqueFieldSchema( + z.string(), + testData.requiredTextField.uniqueId + ); + + const OptionalTextFieldSchema = createUniqueFieldSchema( + z.string().optional(), + testData.optionalTextField.uniqueId + ); + + function RequiredTextField() { + const fieldInfo = useFieldInfo(); + + expect(fieldInfo.isOptional).toBeFalsy(); + expect(fieldInfo.label).toBe(testData.requiredTextField.label); + expect(fieldInfo.placeholder).toBe( + testData.requiredTextField.placeholder + ); + expect(fieldInfo.uniqueId).toBe(testData.requiredTextField.uniqueId); + + return ; + } + + function OptionalTextField() { + const fieldInfo = useFieldInfo(); + + expect(fieldInfo.isOptional).toBe(true); + expect(fieldInfo.label).toBe(testData.optionalTextField.label); + expect(fieldInfo.placeholder).toBe( + testData.optionalTextField.placeholder + ); + expect(fieldInfo.uniqueId).toBe(testData.optionalTextField.uniqueId); + + return ; + } + + const schema = z.object({ + name: RequiredTextFieldSchema.describe(description("requiredTextField")), + nickName: OptionalTextFieldSchema.describe( + description("optionalTextField") + ), + }); + + const mapping = [ + [RequiredTextFieldSchema, RequiredTextField], + [OptionalTextFieldSchema, OptionalTextField], + ] as const; + + const Form = createTsForm(mapping); + + render( {}} />); + }); + it("should be possible to get ZodString information using `useStringFieldInfo`", () => { + const label = "field-label"; + const placeholder = "placeholder"; + const MIN_LENGTH = 5; + const MAX_LENGTH = 16; + const UNIQUE_STRING_SCHEMA_ID = "custom-string"; + + const StringSchema = createUniqueFieldSchema( + z.string().min(MIN_LENGTH).max(MAX_LENGTH), + UNIQUE_STRING_SCHEMA_ID + ); + + const decsription = `${label}${DESCRIPTION_SEPARATOR_SYMBOL}${placeholder}`; + + const schema = z.object({ + name: StringSchema.describe(decsription), + }); + + const mapping = [[StringSchema, CustomTextField]] as const; + + const Form = createTsForm(mapping); + + function CustomTextField() { + const fieldInfo = useStringFieldInfo(); + + return ( +
+
{fieldInfo.minLength}
+
{fieldInfo.maxLength}
+
{fieldInfo.label}
+
{fieldInfo.placeholder}
+
{fieldInfo.uniqueId}
+
+ ); + } + + render( {}} />); + + expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText(placeholder)).toBeInTheDocument(); + expect(screen.getByText(MIN_LENGTH)).toBeInTheDocument(); + expect(screen.getByText(MAX_LENGTH)).toBeInTheDocument(); + expect(screen.getByText(UNIQUE_STRING_SCHEMA_ID)).toBeInTheDocument(); + }); }); + +console.log(useStringFieldInfo); diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 29573a4..977a643 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -397,6 +397,8 @@ export function createTsForm< const { beforeElement, afterElement } = fieldProps; + console.log("PROPS MAP", propsMap); + const mergedProps = { ...(propsMap.name && { [propsMap.name]: key }), ...(propsMap.control && { [propsMap.control]: control }), @@ -420,6 +422,7 @@ export function createTsForm< control={control} name={stringKey} label={ctxLabel} + zodType={type} placeholder={ctxPlaceholder} enumValues={meta.enumValues as string[] | undefined} addToCoerceUndefined={submitter.addToCoerceUndefined} diff --git a/src/index.ts b/src/index.ts index 750dce7..eaff8d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,8 @@ export { useReqDescription, useEnumValues, useTsController, + useFieldInfo, + useStringFieldInfo, + useNumberFieldInfo } from "./FieldContext"; export type { RTFSupportedZodTypes } from "./supportedZodTypes"; diff --git a/src/isZodTypeEqual.tsx b/src/isZodTypeEqual.tsx index 9c2c592..ed40de2 100644 --- a/src/isZodTypeEqual.tsx +++ b/src/isZodTypeEqual.tsx @@ -1,4 +1,13 @@ -import { ZodFirstPartyTypeKind } from "zod"; +import { + AnyZodObject, + ZodArray, + ZodBoolean, + ZodDate, + ZodFirstPartyTypeKind, + ZodNumber, + ZodString, + z, +} from "zod"; import { RTFSupportedZodTypes } from "./supportedZodTypes"; import { unwrap } from "./unwrap"; @@ -19,6 +28,8 @@ export function isZodTypeEqual( if (a._def.typeName !== b._def.typeName) return false; + // array + if ( a._def.typeName === ZodFirstPartyTypeKind.ZodArray && b._def.typeName === ZodFirstPartyTypeKind.ZodArray @@ -27,6 +38,8 @@ export function isZodTypeEqual( return false; } + // set + if ( a._def.typeName === ZodFirstPartyTypeKind.ZodSet && b._def.typeName === ZodFirstPartyTypeKind.ZodSet @@ -35,6 +48,8 @@ export function isZodTypeEqual( return false; } + // map + if ( a._def.typeName === ZodFirstPartyTypeKind.ZodMap && b._def.typeName === ZodFirstPartyTypeKind.ZodMap @@ -103,3 +118,57 @@ export function isZodTypeEqual( } return true; } + +// Guards + +export function isZodString( + zodType: RTFSupportedZodTypes +): zodType is ZodString { + return isTypeOf(zodType, "ZodString"); +} + +export function isZodNumber( + zodType: RTFSupportedZodTypes +): zodType is ZodNumber { + return isTypeOf(zodType, "ZodNumber"); +} + +export function isZodBoolean( + zodType: RTFSupportedZodTypes +): zodType is ZodBoolean { + return isTypeOf(zodType, "ZodBoolean"); +} + +export function isZodArray( + zodType: RTFSupportedZodTypes +): zodType is ZodArray { + return isTypeOf(zodType, "ZodArray"); +} + +export function isZodObject( + zodType: RTFSupportedZodTypes +): zodType is AnyZodObject { + return isTypeOf(zodType, "ZodObject"); +} + +export function isZodDate(zodType: RTFSupportedZodTypes): zodType is ZodDate { + return isTypeOf(zodType, "ZodDate"); +} + +export function isTypeOf(zodType: RTFSupportedZodTypes, type: ZodKindName) { + return zodType._def.typeName === ZodFirstPartyTypeKind[type]; +} + +type ZodKindName = keyof typeof z.ZodFirstPartyTypeKind; + +export type ZodKindNameToType = + InstanceType<(typeof z)[K]>; + +export type RTFSupportedZodFirstPartyTypeKindMap = { + [K in ZodKindName as ZodKindNameToType extends RTFSupportedZodTypes + ? K + : never]: ZodKindNameToType; +}; + +export type RTFSupportedZodFirstPartyTypeKind = + keyof RTFSupportedZodFirstPartyTypeKindMap; diff --git a/src/typeUtilities.ts b/src/typeUtilities.ts index a3ead5c..b8f7797 100644 --- a/src/typeUtilities.ts +++ b/src/typeUtilities.ts @@ -101,3 +101,24 @@ export type IndexOf = { : never : never; }[keyof Indexes]; + +/** + * @internal + */ +export type ExpandRecursively = T extends object + ? T extends infer O + ? { [K in keyof O]: ExpandRecursively } + : never + : T; + +/** + * @internal + */ +export type NullToUndefined = T extends null ? undefined : T; + +/** + * @internal + */ +export type RemoveNull = ExpandRecursively<{ + [K in keyof T]: NullToUndefined; +}>; diff --git a/src/unwrap.tsx b/src/unwrap.tsx index 9177b16..54d5f7a 100644 --- a/src/unwrap.tsx +++ b/src/unwrap.tsx @@ -18,10 +18,14 @@ const unwrappable = new Set([ z.ZodFirstPartyTypeKind.ZodDefault, ]); -export function unwrap(type: RTFSupportedZodTypes): { +export type UnwrappedRTFSupportedZodTypes = { type: RTFSupportedZodTypes; [HIDDEN_ID_PROPERTY]: string | null; -} { +}; + +export function unwrap( + type: RTFSupportedZodTypes +): UnwrappedRTFSupportedZodTypes { // Realized zod has a built in "unwrap()" function after writing this. // Not sure if it's super necessary. let r = type; diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..225ff1c --- /dev/null +++ b/src/utilities.ts @@ -0,0 +1,50 @@ +import { Primitive as ZodPrimitive } from "zod"; +import { RemoveNull } from "./typeUtilities"; + +type InternalKey = `_${string}`; + +type Primitive = Exclude; + +export type PickPrimitiveObjectProperties< + T, + TValue extends false | unknown = false +> = { + [K in keyof T as T[K] extends Exclude + ? Exclude + : never]: TValue extends false ? T[K] : TValue; +}; + +type PickResult = Pick< + T, + Extract +>; + +/** + * Picks only properties with primitive values + * Picks only properties with primitive values from an object and returns a new object with those properties. + * + * @returns A new object containing only the properties with primitive values. + * + */ +export function pickPrimitiveObjectProperties< + T extends object, + TPick extends Partial>, + TObj extends PickPrimitiveObjectProperties = PickPrimitiveObjectProperties, + TResult extends RemoveNull> = RemoveNull< + PickResult + > +>(obj: T, pick: TPick): TResult { + return Object.entries(pick).reduce((result, [key]) => { + const value = obj[key as keyof typeof obj]; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + value === undefined + ) { + (result as any)[key] = value; + } + return result; + }, {} as Pick>) as TResult; +} diff --git a/www/docs/docs/usage/field-schema-info.md b/www/docs/docs/usage/field-schema-info.md new file mode 100644 index 0000000..e0dd32d --- /dev/null +++ b/www/docs/docs/usage/field-schema-info.md @@ -0,0 +1,76 @@ +# Access Schema Data + +From a custom form component, we're able to access field schema information through a set of hooks.
+This allows components to access information about the type and validation rules of the corresponding form field. + + +## `useFieldInfo` + +Returns schema-related information for any field + +```tsx +import { useFieldInfo } from "./FieldContext"; + +const MyCustomField = () => { + const { label, placeholder, isOptional, zodType } = useFieldInfo(); + return ( +
+ {label} + +
+ ); +}; +``` + + +## `useStringFieldInfo` + +Returns schema-related information for a ZodString field + + +```tsx +import { useStringFieldInfo } from "./FieldContext"; + +const MyStringCustomField = () => { + + const { label, placeholder, isOptional, minLength, maxLength, zodType } = + useStringFieldInfo(); + + return ( +
+ {label} + +
+ ); +}; +``` + +## `useNumberFieldInfo` + +Returns schema-related information for a ZodNumber field + +```tsx +import { useNumberFieldInfo } from "./FieldContext"; + +const MyNumberCustomField = () => { + const { label, placeholder, isOptional, minValue, maxValue, isInt, zodType } = + useNumberFieldInfo(); + return ( +
+ {label} + +
+ ); +}; +``` diff --git a/www/package-lock.json b/www/package-lock.json index 0254969..a5cebb1 100644 --- a/www/package-lock.json +++ b/www/package-lock.json @@ -19,6 +19,8 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "2.3.1", + "@docusaurus/theme-common": "^2.3.1", + "@docusaurus/types": "^2.3.1", "@tsconfig/docusaurus": "^1.0.5", "autoprefixer": "^10.4.13", "docusaurus-tailwindcss": "^0.1.0", @@ -29,6 +31,9 @@ }, "engines": { "node": ">=16.14" + }, + "peerDependencies": { + "@docusaurus/theme-common": "^2.1.0" } }, "node_modules/@algolia/autocomplete-core": { diff --git a/www/sidebars.js b/www/sidebars.js index 43c3e0f..b68777b 100644 --- a/www/sidebars.js +++ b/www/sidebars.js @@ -30,6 +30,7 @@ const sidebars = { { type: "doc", id: "docs/usage/default-values" }, { type: "doc", id: "docs/usage/form-state" }, { type: "doc", id: "docs/usage/labels-placeholders" }, + { type: "doc", id: "docs/usage/field-schema-info" }, ], }, { From fb68f8c8bac4305b70c7d059bebf862e5b98e9ec Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Wed, 22 Mar 2023 21:17:37 -0500 Subject: [PATCH 2/6] clean --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7d01095..02e7d19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ts-react/form", - "version": "1.4.5", + "version": "1.0.6", "description": "Build forms faster!", "module": "lib/index.mjs", "main": "lib/index.cjs", @@ -58,4 +58,4 @@ "react-hook-form": "^7.39.0", "zod": "^3.19.0" } -} +} \ No newline at end of file From cba849548abab76eee4b33294d7df59f330ed627 Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Thu, 23 Mar 2023 18:37:07 -0500 Subject: [PATCH 3/6] clean --- src/FieldContext.tsx | 8 +++++--- src/__tests__/createSchemaForm.test.tsx | 6 ++---- src/createSchemaForm.tsx | 2 -- www/docs/docs/usage/field-schema-info.md | 6 +++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 7fbcdce..f3801d2 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -221,7 +221,7 @@ export function fieldSchemaMismatchHookError( hookName: string, expectedType: string ) { - return `Make sure that ${hookName} hook it is being called inside of a custom component which matches the type '${expectedType}'`; + return `Make sure that the '${hookName}' hook is being called inside of a custom form component which matches the type '${expectedType}'`; } /** @@ -299,6 +299,10 @@ export function useFieldInfo() { return internal_useFieldInfo("useFieldInfo"); } +/** + * The zod type objects contain virtual properties which requires us to + * manually pick the properties we'd like inorder to get their values. + */ export function usePickZodFields< TZodKindName extends RTFSupportedZodFirstPartyTypeKind, TZodType extends RTFSupportedZodFirstPartyTypeKindMap[TZodKindName] = RTFSupportedZodFirstPartyTypeKindMap[TZodKindName], @@ -372,5 +376,3 @@ export function useNumberFieldInfo() { "useNumberFieldInfo" ); } - -const {} = useNumberFieldInfo; diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 43f6d6b..76b798e 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -1304,10 +1304,10 @@ describe("createSchemaForm", () => { UNIQUE_STRING_SCHEMA_ID ); - const decsription = `${label}${DESCRIPTION_SEPARATOR_SYMBOL}${placeholder}`; + const description = `${label}${DESCRIPTION_SEPARATOR_SYMBOL}${placeholder}`; const schema = z.object({ - name: StringSchema.describe(decsription), + name: StringSchema.describe(description), }); const mapping = [[StringSchema, CustomTextField]] as const; @@ -1337,5 +1337,3 @@ describe("createSchemaForm", () => { expect(screen.getByText(UNIQUE_STRING_SCHEMA_ID)).toBeInTheDocument(); }); }); - -console.log(useStringFieldInfo); diff --git a/src/createSchemaForm.tsx b/src/createSchemaForm.tsx index 977a643..535680a 100644 --- a/src/createSchemaForm.tsx +++ b/src/createSchemaForm.tsx @@ -397,8 +397,6 @@ export function createTsForm< const { beforeElement, afterElement } = fieldProps; - console.log("PROPS MAP", propsMap); - const mergedProps = { ...(propsMap.name && { [propsMap.name]: key }), ...(propsMap.control && { [propsMap.control]: control }), diff --git a/www/docs/docs/usage/field-schema-info.md b/www/docs/docs/usage/field-schema-info.md index e0dd32d..3ee2c50 100644 --- a/www/docs/docs/usage/field-schema-info.md +++ b/www/docs/docs/usage/field-schema-info.md @@ -9,7 +9,7 @@ This allows components to access information about the type and validation rules Returns schema-related information for any field ```tsx -import { useFieldInfo } from "./FieldContext"; +import { useFieldInfo } from "@ts-react/form"; const MyCustomField = () => { const { label, placeholder, isOptional, zodType } = useFieldInfo(); @@ -29,7 +29,7 @@ Returns schema-related information for a ZodString field ```tsx -import { useStringFieldInfo } from "./FieldContext"; +import { useStringFieldInfo } from "@ts-react/form"; const MyStringCustomField = () => { @@ -55,7 +55,7 @@ const MyStringCustomField = () => { Returns schema-related information for a ZodNumber field ```tsx -import { useNumberFieldInfo } from "./FieldContext"; +import { useNumberFieldInfo } from "@ts-react/form"; const MyNumberCustomField = () => { const { label, placeholder, isOptional, minValue, maxValue, isInt, zodType } = From 547a855d8338fe86dd7c64b986fd898feb176bf7 Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Thu, 23 Mar 2023 18:42:27 -0500 Subject: [PATCH 4/6] remove unused hook --- src/FieldContext.tsx | 10 ------ src/__tests__/createSchemaForm.test.tsx | 42 +------------------------ 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index f3801d2..5ba616d 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -252,16 +252,6 @@ export function useEnumValues() { return enumValues; } -/** - * Returns the Zod type for a field. - * - * @returns The Zod type for the field. - */ -export function useFieldZodType() { - const { zodType } = useContextProt("useFieldZodType"); - return zodType; -} - function getFieldInfo< TZodType extends RTFSupportedZodTypes, TUnwrapZodType extends UnwrapZodType = UnwrapZodType diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 76b798e..c1c522b 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -25,13 +25,11 @@ import { useEnumValues, useReqDescription, useTsController, - useFieldZodType, useStringFieldInfo, useFieldInfo, } from "../FieldContext"; import { expectTypeOf } from "expect-type"; import { createUniqueFieldSchema } from "../createFieldSchema"; -import { unwrap } from "../unwrap"; const testIds = { textField: "_text-field", @@ -1185,45 +1183,7 @@ describe("createSchemaForm", () => { expect(screen.queryByText("one")).toBeInTheDocument(); expect(screen.queryByText("two")).toBeInTheDocument(); }); - it("should be possible to get the Zod type for a field using `useFieldZodType`", () => { - const stringComponentId = "custom-string"; - const numberComponentId = "custom-number"; - - const StringSchema = createUniqueFieldSchema(z.string(), stringComponentId); - const NumberSchema = createUniqueFieldSchema(z.number(), numberComponentId); - - const schema = z.object({ - name: StringSchema.optional(), - age: NumberSchema, - }); - - const mapping = [ - [StringSchema, TextField], - [z.string(), TextField], - [NumberSchema, NumberField], - ] as const; - - const Form = createTsForm(mapping); - - function TextField() { - const zodType = useFieldZodType(); - - expect(unwrap(zodType)._rtf_id).toBe(stringComponentId); - - return ; - } - - function NumberField() { - const zodType = useFieldZodType(); - - expect(unwrap(zodType)._rtf_id).toBe(numberComponentId); - - return ; - } - - render( {}} />); - }); - it.only("should be possible to get ZodAny information using `useFieldInfo`", () => { + it("should be possible to get ZodAny information using `useFieldInfo`", () => { const testData = { requiredTextField: { label: "required-label", From a29769de88ef9bcdb2a213cf1d6d320ed05918a9 Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Thu, 23 Mar 2023 20:25:35 -0500 Subject: [PATCH 5/6] make `useStringFieldInfo` work with `z.string().array()` --- src/FieldContext.tsx | 50 ++++++++++- src/__tests__/createSchemaForm.test.tsx | 107 +++++++++++++++++------- 2 files changed, 122 insertions(+), 35 deletions(-) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 5ba616d..56245f2 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -14,6 +14,7 @@ import { RTFSupportedZodFirstPartyTypeKind, RTFSupportedZodFirstPartyTypeKindMap, isTypeOf, + isZodArray, } from "./isZodTypeEqual"; import { @@ -219,9 +220,10 @@ export function enumValuesNotPassedError() { export function fieldSchemaMismatchHookError( hookName: string, - expectedType: string + { expectedType, receivedType }: { expectedType: string; receivedType: string } ) { - return `Make sure that the '${hookName}' hook is being called inside of a custom form component which matches the type '${expectedType}'`; + return `Make sure that the '${hookName}' hook is being called inside of a custom form component which matches the correct type. + The expected type is '${expectedType}' but the received type was '${receivedType}'`; } /** @@ -304,10 +306,27 @@ export function usePickZodFields< const fieldInfo = internal_useFieldInfo( hookName ); - const { type } = fieldInfo; + + function getType() { + const { type } = fieldInfo; + + if (zodKindName !== "ZodArray" && isZodArray(type)) { + const element = type.element; + return element as any; + } + + return type; + } + + const type = getType(); if (!isTypeOf(type, zodKindName)) { - throw new Error(fieldSchemaMismatchHookError(hookName, zodKindName)); + throw new Error( + fieldSchemaMismatchHookError(hookName, { + expectedType: zodKindName, + receivedType: type._def.typeName, + }) + ); } return { @@ -349,6 +368,29 @@ export function useStringFieldInfo() { ); } +/** + * Returns schema-related information for a ZodString field + * + * @example + * ```tsx + * const CustomComponent = () => { + * const { minLength, maxLength, uniqueId } = useStringFieldInfo(); + * + * return ; + * }; + * ``` + * @returns Information for a ZodString field + */ +export function useArrayFieldInfo() { + return usePickZodFields( + "ZodArray", + { + description: true, + }, + "useArrayFieldInfo" + ); +} + /** * Returns schema-related information for a ZodNumber field * diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index c1c522b..8f738ca 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -1253,47 +1253,92 @@ describe("createSchemaForm", () => { render( {}} />); }); it("should be possible to get ZodString information using `useStringFieldInfo`", () => { - const label = "field-label"; - const placeholder = "placeholder"; - const MIN_LENGTH = 5; - const MAX_LENGTH = 16; - const UNIQUE_STRING_SCHEMA_ID = "custom-string"; + const testData = { + textField: { + uniqueId: "text-field-id", + label: "text-field-label", + placeholder: "text-field-placeholder", + min: 5, + max: 16, + get schema() { + const { min, max, uniqueId } = this; + return createUniqueFieldSchema( + z.string().min(min).max(max), + uniqueId + ); + }, - const StringSchema = createUniqueFieldSchema( - z.string().min(MIN_LENGTH).max(MAX_LENGTH), - UNIQUE_STRING_SCHEMA_ID - ); + get component() { + const { min, max, label, uniqueId } = this; + + const TextFieldComponent = () => { + const fieldInfo = useStringFieldInfo(); + + expect(fieldInfo.minLength).toBe(min); + expect(fieldInfo.maxLength).toBe(max); + expect(fieldInfo.label).toBe(label); + expect(fieldInfo.uniqueId).toBe(uniqueId); + + return
{fieldInfo.label}
; + }; + + return TextFieldComponent; + }, + }, + arrayTextField: { + uniqueId: "array-text-field-id", + label: "array-text-field-label", + placeholder: "array-text-field-placeholder", + min: 5, + max: 16, + get schema() { + const { min, max, uniqueId } = this; + return createUniqueFieldSchema( + z.string().min(min).max(max).array(), + uniqueId + ); + }, + get component() { + const { min, max, label, uniqueId } = this; + + const ArrayTextFieldComponent = () => { + const fieldInfo = useStringFieldInfo(); + + expect(fieldInfo.minLength).toBe(min); + expect(fieldInfo.maxLength).toBe(max); + expect(fieldInfo.label).toBe(label); + expect(fieldInfo.uniqueId).toBe(uniqueId); + + return
{fieldInfo.label}
; + }; + + return ArrayTextFieldComponent; + }, + }, + }; - const description = `${label}${DESCRIPTION_SEPARATOR_SYMBOL}${placeholder}`; + const description = (k: keyof typeof testData) => + `${testData[k].label}${DESCRIPTION_SEPARATOR_SYMBOL}${testData[k].placeholder}`; + + const { textField, arrayTextField } = testData; const schema = z.object({ - name: StringSchema.describe(description), + name: textField.schema.describe(description("textField")), + users: arrayTextField.schema.describe(description("arrayTextField")), }); - const mapping = [[StringSchema, CustomTextField]] as const; + const mapping = [ + [textField.schema, textField.component], + [arrayTextField.schema, arrayTextField.component], + ] as const; const Form = createTsForm(mapping); - function CustomTextField() { - const fieldInfo = useStringFieldInfo(); - - return ( -
-
{fieldInfo.minLength}
-
{fieldInfo.maxLength}
-
{fieldInfo.label}
-
{fieldInfo.placeholder}
-
{fieldInfo.uniqueId}
-
- ); - } - render( {}} />); - expect(screen.getByText(label)).toBeInTheDocument(); - expect(screen.getByText(placeholder)).toBeInTheDocument(); - expect(screen.getByText(MIN_LENGTH)).toBeInTheDocument(); - expect(screen.getByText(MAX_LENGTH)).toBeInTheDocument(); - expect(screen.getByText(UNIQUE_STRING_SCHEMA_ID)).toBeInTheDocument(); + expect(screen.queryByText(testData.textField.label)).toBeInTheDocument(); + expect( + screen.queryByText(testData.arrayTextField.label) + ).toBeInTheDocument(); }); }); From 6a4f32e3badc5491ffe95f145a971670303c50d4 Mon Sep 17 00:00:00 2001 From: Tyler Mitchell Date: Thu, 23 Mar 2023 23:46:19 -0500 Subject: [PATCH 6/6] add `defaultValue` to `useFieldInfo` return --- src/FieldContext.tsx | 15 +++++++++++++++ src/__tests__/createSchemaForm.test.tsx | 13 +++++++++++++ src/isZodTypeEqual.tsx | 10 ++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/FieldContext.tsx b/src/FieldContext.tsx index 56245f2..1453bcb 100644 --- a/src/FieldContext.tsx +++ b/src/FieldContext.tsx @@ -15,12 +15,14 @@ import { RTFSupportedZodFirstPartyTypeKindMap, isTypeOf, isZodArray, + isZodDefaultDef, } from "./isZodTypeEqual"; import { PickPrimitiveObjectProperties, pickPrimitiveObjectProperties, } from "./utilities"; +import { ZodDefaultDef } from "zod"; export const FieldContext = createContext; @@ -260,15 +262,28 @@ function getFieldInfo< >(zodType: TZodType) { const { type, _rtf_id } = unwrap(zodType); + function getDefaultValue() { + const def = zodType._def; + if (isZodDefaultDef(def)) { + const defaultValue = (def as ZodDefaultDef).defaultValue(); + return defaultValue; + } + return undefined; + } + return { type: type as TUnwrapZodType, zodType, uniqueId: _rtf_id ?? undefined, isOptional: zodType.isOptional(), isNullable: zodType.isNullable(), + defaultValue: getDefaultValue(), }; } +/** + * @internal + */ export function internal_useFieldInfo< TZodType extends RTFSupportedZodTypes = RTFSupportedZodTypes, TUnwrappedZodType extends UnwrapZodType = UnwrapZodType diff --git a/src/__tests__/createSchemaForm.test.tsx b/src/__tests__/createSchemaForm.test.tsx index 8f738ca..1037a54 100644 --- a/src/__tests__/createSchemaForm.test.tsx +++ b/src/__tests__/createSchemaForm.test.tsx @@ -1236,7 +1236,19 @@ describe("createSchemaForm", () => { return ; } + const defaultEmail = "john@example.com"; + + const DefaultTextField = () => { + // @ts-expect-error + const { defaultValue, type, zodType } = useFieldInfo(); + + expect(defaultValue).toBe(defaultEmail); + + return ; + }; + const schema = z.object({ + email: z.string().default(defaultEmail), name: RequiredTextFieldSchema.describe(description("requiredTextField")), nickName: OptionalTextFieldSchema.describe( description("optionalTextField") @@ -1244,6 +1256,7 @@ describe("createSchemaForm", () => { }); const mapping = [ + [z.string(), DefaultTextField], [RequiredTextFieldSchema, RequiredTextField], [OptionalTextFieldSchema, OptionalTextField], ] as const; diff --git a/src/isZodTypeEqual.tsx b/src/isZodTypeEqual.tsx index ed40de2..e0fd73d 100644 --- a/src/isZodTypeEqual.tsx +++ b/src/isZodTypeEqual.tsx @@ -3,6 +3,7 @@ import { ZodArray, ZodBoolean, ZodDate, + ZodDefaultDef, ZodFirstPartyTypeKind, ZodNumber, ZodString, @@ -151,6 +152,15 @@ export function isZodObject( return isTypeOf(zodType, "ZodObject"); } +export function isZodDefaultDef(zodDef: unknown): zodDef is ZodDefaultDef { + return Boolean( + zodDef && + typeof zodDef === "object" && + "defaultValue" in zodDef && + typeof zodDef.defaultValue === "function" + ); +} + export function isZodDate(zodType: RTFSupportedZodTypes): zodType is ZodDate { return isTypeOf(zodType, "ZodDate"); }