Skip to content

Instantly share code, notes, and snippets.

@akaia-shadowfox
Created August 27, 2024 05:52
Show Gist options
  • Save akaia-shadowfox/93328325d50d727895527453fe23d451 to your computer and use it in GitHub Desktop.
Save akaia-shadowfox/93328325d50d727895527453fe23d451 to your computer and use it in GitHub Desktop.
[Draft] Cross-field react-hook-form validation against Zod schema
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),
};
};
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