Skip to content

Commit

Permalink
Merge pull request iway1#95 from tyler-mitchell/schema-aware-field-hooks
Browse files Browse the repository at this point in the history
add schema-aware field hooks
  • Loading branch information
iway1 committed Mar 29, 2023
2 parents e80f5df + 6a4f32e commit 8e4700d
Show file tree
Hide file tree
Showing 14 changed files with 641 additions and 12 deletions.
28 changes: 23 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -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",
Expand All @@ -57,4 +58,4 @@
"react-hook-form": "^7.39.0",
"zod": "^3.19.0"
}
}
}
8 changes: 5 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

195 changes: 195 additions & 0 deletions src/FieldContext.tsx
Expand Up @@ -8,13 +8,29 @@ 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,
isZodArray,
isZodDefaultDef,
} from "./isZodTypeEqual";

import {
PickPrimitiveObjectProperties,
pickPrimitiveObjectProperties,
} from "./utilities";
import { ZodDefaultDef } from "zod";

export const FieldContext = createContext<null | {
control: Control<any>;
name: string;
label?: string;
placeholder?: string;
enumValues?: string[];
zodType: RTFSupportedZodTypes;
addToCoerceUndefined: (v: string) => void;
removeFromCoerceUndefined: (v: string) => void;
}>(null);
Expand All @@ -26,6 +42,7 @@ export function FieldContextProvider({
label,
placeholder,
enumValues,
zodType,
addToCoerceUndefined,
removeFromCoerceUndefined,
}: {
Expand All @@ -35,6 +52,7 @@ export function FieldContextProvider({
placeholder?: string;
enumValues?: string[];
children: ReactNode;
zodType: RTFSupportedZodTypes;
addToCoerceUndefined: (v: string) => void;
removeFromCoerceUndefined: (v: string) => void;
}) {
Expand All @@ -46,6 +64,7 @@ export function FieldContextProvider({
label,
placeholder,
enumValues,
zodType,
addToCoerceUndefined,
removeFromCoerceUndefined,
}}
Expand Down Expand Up @@ -201,6 +220,14 @@ 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, receivedType }: { expectedType: string; receivedType: string }
) {
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}'`;
}

/**
* 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).
Expand Down Expand Up @@ -228,3 +255,171 @@ export function useEnumValues() {
if (!enumValues) throw new Error(enumValuesNotPassedError());
return enumValues;
}

function getFieldInfo<
TZodType extends RTFSupportedZodTypes,
TUnwrapZodType extends UnwrapZodType<TZodType> = UnwrapZodType<TZodType>
>(zodType: TZodType) {
const { type, _rtf_id } = unwrap(zodType);

function getDefaultValue() {
const def = zodType._def;
if (isZodDefaultDef(def)) {
const defaultValue = (def as ZodDefaultDef<TZodType>).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<TZodType> = UnwrapZodType<TZodType>
>(hookName: string) {
const { zodType, label, placeholder } = useContextProt(hookName);

const fieldInfo = getFieldInfo<TZodType, TUnwrappedZodType>(
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");
}

/**
* 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],
TUnwrappedZodType extends UnwrapZodType<TZodType> = UnwrapZodType<TZodType>,
TPick extends Partial<
PickPrimitiveObjectProperties<TUnwrappedZodType, true>
> = Partial<PickPrimitiveObjectProperties<TUnwrappedZodType, true>>
>(zodKindName: TZodKindName, pick: TPick, hookName: string) {
const fieldInfo = internal_useFieldInfo<TZodType, TUnwrappedZodType>(
hookName
);

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, {
expectedType: zodKindName,
receivedType: type._def.typeName,
})
);
}

return {
...pickPrimitiveObjectProperties<TUnwrappedZodType, TPick>(type, pick),
...fieldInfo,
};
}

/**
* Returns schema-related information for a ZodString field
*
* @example
* ```tsx
* const CustomComponent = () => {
* const { minLength, maxLength, uniqueId } = useStringFieldInfo();
*
* return <input minLength={minLength} maxLength={maxLength} />;
* };
* ```
* @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 ZodString field
*
* @example
* ```tsx
* const CustomComponent = () => {
* const { minLength, maxLength, uniqueId } = useStringFieldInfo();
*
* return <input minLength={minLength} maxLength={maxLength} />;
* };
* ```
* @returns Information for a ZodString field
*/
export function useArrayFieldInfo() {
return usePickZodFields(
"ZodArray",
{
description: true,
},
"useArrayFieldInfo"
);
}

/**
* 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"
);
}

0 comments on commit 8e4700d

Please sign in to comment.