Created
August 27, 2024 05:52
-
-
Save akaia-shadowfox/93328325d50d727895527453fe23d451 to your computer and use it in GitHub Desktop.
[Draft] Cross-field react-hook-form validation against Zod schema
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useMemo } from "react"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { useRouter } from "next/router"; | |
import { FieldErrors, SubmitHandler, useForm, useWatch } from "react-hook-form"; | |
import { walletApi } from "@/common/api/near"; | |
import { | |
POTLOCK_CONTRACT_REPO_URL, | |
POTLOCK_CONTRACT_VERSION, | |
} from "@/common/constants"; | |
import { AccountId } from "@/common/types"; | |
import { useCoreState } from "@/modules/core"; | |
import { dispatch } from "@/store"; | |
import { | |
PotDeploymentInputs, | |
potCrossFieldValidationTargets, | |
potDeploymentSchema, | |
} from "../models"; | |
export const usePotDeploymentForm = () => { | |
const router = useRouter(); | |
const { | |
contractMetadata: { latestSourceCodeCommitHash }, | |
} = useCoreState(); | |
const defaultValues = useMemo<Partial<PotDeploymentInputs>>( | |
() => ({ | |
source_metadata: { | |
version: POTLOCK_CONTRACT_VERSION, | |
commit_hash: latestSourceCodeCommitHash, | |
link: POTLOCK_CONTRACT_REPO_URL, | |
}, | |
owner: walletApi.accountId, | |
max_projects: 25, | |
isPgRegistrationRequired: false, | |
isNadabotVerificationRequired: true, | |
}), | |
[latestSourceCodeCommitHash], | |
); | |
const form = useForm<PotDeploymentInputs>({ | |
resolver: zodResolver(potDeploymentSchema), | |
mode: "onChange", | |
defaultValues, | |
resetOptions: { keepDirtyValues: true }, | |
}); | |
const formValues = useWatch({ control: form.control }); | |
const crossFieldErrors = useMemo<FieldErrors<PotDeploymentInputs>>( | |
() => | |
potDeploymentSchema | |
.safeParse(formValues as PotDeploymentInputs) | |
.error?.issues.reduce((schemaErrors, { code, message, path }) => { | |
const fieldPath = path.at(0); | |
console.table({ code, message, fieldPath }); | |
return potCrossFieldValidationTargets.includes( | |
fieldPath as keyof PotDeploymentInputs, | |
) && typeof fieldPath === "string" | |
? { ...schemaErrors, [fieldPath]: { message, type: code } } | |
: schemaErrors; | |
}, {}) ?? {}, | |
[formValues], | |
); | |
const errors: FieldErrors<PotDeploymentInputs> = { | |
...form.formState.errors, | |
...crossFieldErrors, | |
}; | |
console.log("cross field errors", crossFieldErrors); | |
console.log("combined errors", errors); | |
const isDisabled = | |
formValues.source_metadata === null || | |
!form.formState.isDirty || | |
!form.formState.isValid || | |
form.formState.isSubmitting; | |
const handleAdminsUpdate = useCallback( | |
(accountIds: AccountId[]) => form.setValue("admins", accountIds), | |
[form], | |
); | |
const onCancel = () => { | |
form.reset(); | |
router.back(); | |
}; | |
const onSubmit: SubmitHandler<PotDeploymentInputs> = useCallback( | |
(inputs) => dispatch.pot.deploy(inputs), | |
[], | |
); | |
return { | |
form, | |
formValues, | |
crossFieldErrors, | |
handleAdminsUpdate, | |
isDisabled, | |
onCancel, | |
onSubmit: form.handleSubmit(onSubmit), | |
}; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { infer as FromSchema, array, boolean, object, string } from "zod"; | |
import { futureTimestamp, safePositiveNumber } from "@/common/lib"; | |
import { validAccountId } from "@/modules/core"; | |
import { | |
donationAmount, | |
donationFeeBasisPoints, | |
donationFeeBasisPointsToPercents, | |
} from "@/modules/donation"; | |
import { | |
POT_MAX_APPROVED_PROJECTS, | |
POT_MAX_CHEF_FEE_BASIS_POINTS, | |
POT_MAX_DESCRIPTION_LENGTH, | |
POT_MAX_NAME_LENGTH, | |
POT_MAX_REFERRAL_FEE_MATCHING_POOL_BASIS_POINTS, | |
POT_MAX_REFERRAL_FEE_PUBLIC_ROUND_BASIS_POINTS, | |
POT_MIN_COOLDOWN_PERIOD_MS, | |
POT_MIN_NAME_LENGTH, | |
} from "../constants"; | |
import { | |
isPotApplicationStartBeforeEnd, | |
isPotChefFeeValid, | |
isPotCooldownPeriodValid, | |
isPotMatchingPoolReferralFeeValid, | |
isPotMaxProjectsValid, | |
isPotPublicRoundReferralFeeValid, | |
isPotPublicRoundStartAfterApplicationEnd, | |
isPotPublicRoundStartBeforeEnd, | |
} from "../utils/validation"; | |
export const potDeploymentSchema = object({ | |
source_metadata: object({ | |
commit_hash: string().nullable(), | |
link: string(), | |
version: string(), | |
}), | |
owner: string().describe("Owner's account id."), | |
admins: array(string()) | |
.optional() | |
.describe("List of pot admins' account ids."), | |
chef: validAccountId.optional().describe("Chef's account id."), | |
pot_name: string() | |
.min( | |
POT_MIN_NAME_LENGTH, | |
`Must be at least ${POT_MIN_NAME_LENGTH} characters long.`, | |
) | |
.max( | |
POT_MAX_NAME_LENGTH, | |
`Must be less than ${POT_MAX_NAME_LENGTH} characters long.`, | |
) | |
.describe("Pot name."), | |
pot_handle: string().optional().describe("Pot handle."), | |
pot_description: string() | |
.max( | |
POT_MAX_DESCRIPTION_LENGTH, | |
`Cannot be longer than ${POT_MAX_DESCRIPTION_LENGTH} characters.`, | |
) | |
.describe("Pot description."), | |
max_projects: safePositiveNumber | |
.refine(isPotMaxProjectsValid, { | |
message: `Cannot exceed ${POT_MAX_APPROVED_PROJECTS}`, | |
}) | |
.describe("Maximum number of approved projects."), | |
application_start_ms: futureTimestamp.describe( | |
"Application start timestamp.", | |
), | |
application_end_ms: futureTimestamp.describe("Application end timestamp."), | |
public_round_start_ms: futureTimestamp.describe( | |
"Matching round start timestamp.", | |
), | |
public_round_end_ms: futureTimestamp.describe( | |
"Matching round end timestamp.", | |
), | |
min_matching_pool_donation_amount: donationAmount | |
.optional() | |
.describe("Minimum donation amount."), | |
cooldown_period_ms: safePositiveNumber | |
.refine(isPotCooldownPeriodValid, { | |
message: `Cooldown period must be at least ${POT_MIN_COOLDOWN_PERIOD_MS} ms`, | |
}) | |
.optional() | |
.describe("Cooldown period in milliseconds."), | |
registry_provider: string() | |
.optional() | |
.describe("Registry provider's account id."), | |
isPgRegistrationRequired: boolean() | |
.optional() | |
.describe( | |
"Whether the projects must be included in PotLock PG registry with approval.", | |
), | |
sybil_wrapper_provider: string() | |
.optional() | |
.describe("Sybil wrapper provider's account id."), | |
isNadabotVerificationRequired: boolean() | |
.optional() | |
.describe("Whether the projects must have Nadabot verification."), | |
referral_fee_matching_pool_basis_points: donationFeeBasisPoints | |
.refine(isPotMatchingPoolReferralFeeValid, { | |
message: `Cannot exceed ${donationFeeBasisPointsToPercents( | |
POT_MAX_REFERRAL_FEE_MATCHING_POOL_BASIS_POINTS, | |
)}%.`, | |
}) | |
.describe("Matching pool referral fee in basis points."), | |
referral_fee_public_round_basis_points: donationFeeBasisPoints | |
.refine(isPotPublicRoundReferralFeeValid, { | |
message: `Cannot exceed ${donationFeeBasisPointsToPercents( | |
POT_MAX_REFERRAL_FEE_PUBLIC_ROUND_BASIS_POINTS, | |
)}%.`, | |
}) | |
.describe("Public round referral fee in basis points."), | |
chef_fee_basis_points: donationFeeBasisPoints | |
.refine(isPotChefFeeValid, { | |
message: `Cannot exceed ${donationFeeBasisPointsToPercents( | |
POT_MAX_CHEF_FEE_BASIS_POINTS, | |
)}%.`, | |
}) | |
.describe("Chef fee in basis points."), | |
}) | |
/** | |
*! Heads up! | |
*! Make sure that any fields targeted here are listed in `potCrossFieldValidationTargets` | |
*! and have their corresponding error paths specified correctly. | |
*/ | |
.refine(isPotApplicationStartBeforeEnd, { | |
message: "Application cannot end before it starts.", | |
path: ["application_end_ms"], | |
}) | |
.refine(isPotPublicRoundStartBeforeEnd, { | |
message: "Public round cannot end before it starts.", | |
path: ["public_round_end_ms"], | |
}) | |
.refine(isPotPublicRoundStartAfterApplicationEnd, { | |
message: "Public round can only start after application period ends.", | |
path: ["public_round_start_ms"], | |
}); | |
export type PotDeploymentInputs = FromSchema<typeof potDeploymentSchema>; | |
export const potCrossFieldValidationTargets: (keyof PotDeploymentInputs)[] = [ | |
"application_end_ms", | |
"public_round_end_ms", | |
"public_round_start_ms", | |
]; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment