Skip to content

Commit fbd4009

Browse files
authored
Implement generalized solution for cross-field validation (#352)
1 parent fad62f6 commit fbd4009

File tree

276 files changed

+598
-558
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

276 files changed

+598
-558
lines changed

components.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
"tsx": true,
66
"tailwind": {
77
"config": "tailwind.config.ts",
8-
"css": "src/common/ui/styles/theme.css",
8+
"css": "src/common/ui/layout/styles/theme.css",
99
"baseColor": "slate",
1010
"cssVariables": true,
1111
"prefix": ""
1212
},
1313
"aliases": {
14-
"components": "@/common/ui",
15-
"utils": "@/common/ui/utils",
16-
"ui": "@/common/ui/components"
14+
"components": "@/common/ui/layout",
15+
"utils": "@/common/ui/layout/utils",
16+
"ui": "@/common/ui/layout/components"
1717
}
1818
}

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"typecheck": "tsc --noEmit",
1212
"generate:api-clients": "orval --config api.config.cjs",
1313
"generate:clients": "concurrently 'yarn generate:api-clients'",
14-
"generate:css": "unocss 'src/**/*.tsx' --out-file=src/common/ui/styles/uno.generated.css --write-transformed",
14+
"generate:css": "unocss 'src/**/*.tsx' --out-file=src/common/ui/layout/styles/uno.generated.css --write-transformed",
1515
"dev:css": "yarn generate:css --watch",
1616
"dev:test": "yarn && vitest watch",
1717
"dev": "yarn && yarn generate:css && concurrently 'yarn dev:css' 'next dev'",

src/common/constants.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export const NATIVE_TOKEN_DECIMALS = NEAR_NOMINATION_EXP;
114114
export const NATIVE_TOKEN_ICON_URL = `${ICONS_ASSET_ENDPOINT_URL}/near.svg`;
115115
export const UNKNOWN_ACCOUNT_ID_PLACEHOLDER = "unknown-account-id";
116116

117-
export const PLATFORM_LISTED_TOKEN_IDS: TokenId[] = [NATIVE_TOKEN_ID];
117+
export const PLATFORM_LISTED_TOKEN_IDS: TokenId[] = [];
118118

119119
// List ID of PotLock Public Goods Registry
120120
export const PUBLIC_GOODS_REGISTRY_LIST_ID = 1;

src/common/ui/form-fields/checkbox.tsx src/common/ui/form/components/checkbox.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Checkbox } from "../components";
2-
import { FormControl, FormItem, FormLabel } from "../components/molecules/form";
1+
import { Checkbox, FormControl, FormItem, FormLabel } from "../../layout/components";
32

43
export type CheckboxFieldProps = Pick<
54
React.ComponentProps<typeof Checkbox>,
File renamed without changes.

src/common/ui/form-fields/select.tsx src/common/ui/form/components/select.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
SelectLabel,
1414
SelectTrigger,
1515
SelectValue,
16-
} from "../components";
16+
} from "../../layout/components";
1717

1818
export const SelectFieldOption = SelectItem;
1919

src/common/ui/form-fields/text.tsx src/common/ui/form/components/text.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { forwardRef, useMemo } from "react";
22

3-
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "../components";
4-
import { cn } from "../utils";
3+
import {
4+
FormControl,
5+
FormDescription,
6+
FormItem,
7+
FormLabel,
8+
FormMessage,
9+
} from "../../layout/components";
10+
import { cn } from "../../layout/utils";
511

612
export type TextFieldProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> & {
713
type: "email" | "text" | "number" | "tel" | "url" | "datetime-local";

src/common/ui/form-fields/textarea.tsx src/common/ui/form/components/textarea.tsx

+14-10
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
FormMessage,
99
Textarea,
1010
TextareaProps,
11-
} from "../components";
12-
import { cn } from "../utils";
11+
} from "../../layout/components";
12+
import { cn } from "../../layout/utils";
1313

1414
export type TextAreaFieldProps = TextareaProps & {
1515
label: string;
@@ -30,7 +30,9 @@ export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>
3030
<div un-flex="~" un-items="center" un-gap="1">
3131
{label && <FormLabel className="font-500 text-sm">{label}</FormLabel>}
3232

33-
{props.required && <span className="line-height-none text-destructive text-xl">*</span>}
33+
{props.required && (
34+
<span className="line-height-none text-destructive text-xl">{"*"}</span>
35+
)}
3436
</div>
3537

3638
{labelExtension ??
@@ -48,15 +50,17 @@ export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>
4850
/>
4951
</FormControl>
5052

51-
<FormDescription className="">
52-
{hint && <span>{hint}</span>}
53+
<div className="flex justify-between">
54+
<FormMessage>{customErrorMessage}</FormMessage>
5355

54-
<span className="prose ml-auto">
55-
{typeof maxLength === "number" ? `${currentLength}/${maxLength}` : currentLength}
56-
</span>
57-
</FormDescription>
56+
<FormDescription className="ml-a">
57+
{hint && <span>{hint}</span>}
5858

59-
<FormMessage>{customErrorMessage}</FormMessage>
59+
<span className="prose ml-auto">
60+
{typeof maxLength === "number" ? `${currentLength}/${maxLength}` : currentLength}
61+
</span>
62+
</FormDescription>
63+
</div>
6064
</FormItem>
6165
);
6266
},

src/common/ui/form/hooks/enhanced.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useEffect } from "react";
2+
3+
import { zodResolver } from "@hookform/resolvers/zod";
4+
import { type UseFormProps, type UseFormReturn, useForm } from "react-hook-form";
5+
import { isDeepEqual, keys, pick } from "remeda";
6+
import type { infer as FromSchema, ZodSchema } from "zod";
7+
8+
import {
9+
type FormCrossFieldZodValidationParams,
10+
useFormCrossFieldZodValidation,
11+
} from "./zod-validation";
12+
13+
export type EnhancedFormProps<TSchema extends ZodSchema> = Omit<
14+
UseFormProps<FromSchema<TSchema>>,
15+
"resolver"
16+
> &
17+
Partial<Pick<FormCrossFieldZodValidationParams<TSchema>, "dependentFields">> & {
18+
schema: TSchema;
19+
/**
20+
* Whether to keep track of the `defaultValues` state and re-populate the values automatically.
21+
*
22+
* @default false
23+
*/
24+
followDefaultValues?: boolean;
25+
};
26+
27+
export type EnhancedFormBindings = {
28+
/**
29+
* Indicates whether the form is out of sync with the current defaultValues state.
30+
*
31+
* Useful when `defaultValues` is reactive and can potentially change.
32+
*/
33+
isUnpopulated: boolean;
34+
};
35+
36+
/**
37+
* Enhanced version of react-hook-form's useForm that includes cross-field validation
38+
* functionality and enforces Zod schema validation.
39+
*
40+
* @remarks
41+
* This hook combines the power of react-hook-form with Zod validation and adds support
42+
* for complex cross-field validation scenarios.
43+
*
44+
* @param props - form configuration and validation parameters. {@link EnhancedFormProps}
45+
*
46+
* @returns object - form with enhancements. {@link UseFormReturn} & {@link EnhancedFormBindings}
47+
*
48+
* @example
49+
* ```tsx
50+
* const schema = z.object({
51+
* password: z.string().min(8),
52+
* passwordConfirmation: z.string()
53+
* }).refine(data => data.password === data.passwordConfirmation, {
54+
* message: "Passwords don't match",
55+
* path: ["passwordConfirmation"]
56+
* });
57+
*
58+
* function App() {
59+
* const form = useEnhancedForm({
60+
* schema,
61+
* dependentFields: ["passwordConfirmation"],
62+
* defaultValues: {
63+
* password: "",
64+
* passwordConfirmation: ""
65+
* }
66+
* });
67+
*
68+
* return (
69+
* <form onSubmit={form.handleSubmit(onSubmit)}>
70+
* <input {...form.register("password")} />
71+
* <input {...form.register("passwordConfirmation")} />
72+
* {form.formState.errors.passwordConfirmation && (
73+
* <span>{form.formState.errors.passwordConfirmation.message}</span>
74+
* )}
75+
* <button type="submit">Submit</button>
76+
* </form>
77+
* );
78+
* }
79+
* ```
80+
*/
81+
export const useEnhancedForm = <TSchema extends ZodSchema>({
82+
schema,
83+
dependentFields = [],
84+
defaultValues,
85+
followDefaultValues = false,
86+
...formProps
87+
}: EnhancedFormProps<TSchema>): {
88+
form: UseFormReturn<FromSchema<TSchema>>;
89+
} & EnhancedFormBindings => {
90+
const self = useForm<FromSchema<TSchema>>({
91+
...formProps,
92+
resolver: zodResolver(schema),
93+
defaultValues,
94+
});
95+
96+
useFormCrossFieldZodValidation({ form: self, schema, dependentFields });
97+
98+
const isUnpopulated =
99+
!isDeepEqual(
100+
defaultValues,
101+
pick(self.formState.defaultValues ?? {}, keys(defaultValues ?? {})),
102+
) && !self.formState.isDirty;
103+
104+
useEffect(() => {
105+
if (followDefaultValues && isUnpopulated) {
106+
self.reset(defaultValues);
107+
}
108+
}, [isUnpopulated, self, defaultValues, followDefaultValues]);
109+
110+
return { form: self, isUnpopulated };
111+
};

src/common/ui/form/hooks/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./enhanced";
2+
export * from "./zod-validation";
+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useEffect } from "react";
2+
3+
import { type Path, type UseFormReturn, useWatch } from "react-hook-form";
4+
import type { infer as FromSchema, ZodError, ZodSchema } from "zod";
5+
6+
export type FormCrossFieldZodValidationParams<TSchema extends ZodSchema> = {
7+
/**
8+
* The form instance returned by react-hook-form's useForm
9+
*/
10+
form: UseFormReturn<FromSchema<TSchema>>;
11+
/**
12+
* A Zod schema that defines the validation rules including cross-field validations
13+
*/
14+
schema: TSchema;
15+
/**
16+
* Array of field names that should be validated when other fields change
17+
*/
18+
dependentFields: Array<keyof FromSchema<TSchema>>;
19+
};
20+
21+
/**
22+
* Performs complex form validation where the validity of one field depends on
23+
* the state of other fields - a scenario not handled by react-hook-form's built-in validation.
24+
*
25+
* The hook watches for changes in form values and triggers Zod schema validation,
26+
* setting errors on the specified target fields when validation fails.
27+
*
28+
* @param params - The parameters object. {@link FormCrossFieldZodValidationParams}
29+
*
30+
* @example
31+
* ```typescript
32+
* const schema = z.object({
33+
* password: z.string(),
34+
* passwordConfirmation: z.string()
35+
* }).refine(data => data.password === data.passwordConfirmation, {
36+
* message: "Passwords don't match",
37+
* path: ["passwordConfirmation"]
38+
* });
39+
*
40+
* const form = useForm<z.infer<typeof schema>>();
41+
*
42+
* useFormCrossFieldValidation({
43+
* form,
44+
* schema,
45+
* dependentFields: ["passwordConfirmation"]
46+
* });
47+
* ```
48+
*
49+
* @returns void - This hook only produces side effects (setting form errors)
50+
*/
51+
export const useFormCrossFieldZodValidation = <TSchema extends ZodSchema>({
52+
form,
53+
schema,
54+
dependentFields,
55+
}: FormCrossFieldZodValidationParams<TSchema>) => {
56+
type Inputs = FromSchema<TSchema>;
57+
58+
const values = useWatch({ control: form.control });
59+
60+
useEffect(() => {
61+
if (dependentFields.length > 0) {
62+
schema.parseAsync(values).catch((error?: ZodError) =>
63+
error?.issues.forEach(({ code, message, path }) => {
64+
const fieldPath = path.at(0);
65+
66+
if (
67+
dependentFields.includes(fieldPath as keyof Inputs) &&
68+
typeof fieldPath === "string" &&
69+
code === "custom"
70+
) {
71+
form.setError(fieldPath as Path<Inputs>, { message, type: code });
72+
}
73+
}),
74+
);
75+
}
76+
}, [schema, form, values, dependentFields]);
77+
};

src/common/ui/components/atoms/badge.tsx src/common/ui/layout/components/atoms/badge.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from "react";
22

33
import { type VariantProps, cva } from "class-variance-authority";
44

5-
import { cn } from "@/common/ui/utils";
5+
import { cn } from "@/common/ui/layout/utils";
66

77
const badgeVariants = cva(
88
cn(

src/common/ui/components/atoms/hover-card.tsx src/common/ui/layout/components/atoms/hover-card.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { forwardRef } from "react";
44

55
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
66

7-
import { cn } from "@/common/ui/utils";
7+
import { cn } from "@/common/ui/layout/utils";
88

99
const HoverCard = HoverCardPrimitive.Root;
1010

src/common/ui/components/atoms/scroll-area.tsx src/common/ui/layout/components/atoms/scroll-area.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { forwardRef } from "react";
44

55
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
66

7-
import { cn } from "@/common/ui/utils";
7+
import { cn } from "@/common/ui/layout/utils";
88

99
const ScrollArea = forwardRef<
1010
React.ElementRef<typeof ScrollAreaPrimitive.Root>,

src/common/ui/components/atoms/splash-screen.tsx src/common/ui/layout/components/atoms/splash-screen.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PLATFORM_NAME } from "@/common/_config";
2-
import { cn } from "@/common/ui/utils";
2+
import { cn } from "@/common/ui/layout/utils";
33

44
export type SplashScreenProps = {
55
className?: string;

src/common/ui/components/atoms/table.tsx src/common/ui/layout/components/atoms/table.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22

3-
import { cn } from "@/common/ui/utils";
3+
import { cn } from "@/common/ui/layout/utils";
44

55
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
66
({ className, ...props }, ref) => (

src/common/ui/components/atoms/tooltip.tsx src/common/ui/layout/components/atoms/tooltip.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { forwardRef } from "react";
22

33
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
44

5-
import { cn } from "@/common/ui/utils";
5+
import { cn } from "@/common/ui/layout/utils";
66

77
const TooltipProvider = TooltipPrimitive.Provider;
88

File renamed without changes.

src/common/ui/components/molecules/clipboard-copy-button.tsx src/common/ui/layout/components/molecules/clipboard-copy-button.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { ReactElement, useCallback, useState } from "react";
22

33
import { CopyToClipboard } from "react-copy-to-clipboard";
44

5-
import { CopyPasteIcon } from "@/common/ui/svg";
5+
import { CopyPasteIcon } from "@/common/ui/layout/svg";
66

77
export type ClipboardCopyButtonProps = {
88
customIcon?: ReactElement;

src/common/ui/components/molecules/radio-group.tsx src/common/ui/layout/components/molecules/radio-group.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { forwardRef } from "react";
22

33
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
44

5-
import { RadioActiveIcon, RadioInactiveIcon } from "@/common/ui/svg";
5+
import { RadioActiveIcon, RadioInactiveIcon } from "@/common/ui/layout/svg";
66

77
import { cn } from "../../utils";
88
import { Label } from "../atoms/label";

src/common/ui/components/molecules/social-share.tsx src/common/ui/layout/components/molecules/social-share.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { useCallback, useState } from "react";
33
import { Check } from "lucide-react";
44
import CopyToClipboard from "react-copy-to-clipboard";
55

6-
import { CopyPasteIcon } from "@/common/ui/svg";
6+
import { CopyPasteIcon } from "@/common/ui/layout/svg";
77
import {
88
InstagramShareIcon,
99
ShareIcon,
1010
TelegramShareIcon,
1111
TwitterShareIcon,
12-
} from "@/common/ui/svg/Share";
12+
} from "@/common/ui/layout/svg/Share";
1313

1414
import { Button } from "../atoms/button";
1515
import { Popover, PopoverContent, PopoverTrigger } from "../atoms/popover";

0 commit comments

Comments
 (0)