react-hook-form
和 zod
建立表單和進行表單驗證今天,我們著重於創建不同類型的表單,涵蓋了登入表單、結帳表單以及購物車表單。
首先,我們進行了必要的設置,來安裝zod和react-hook-form等依賴
pnpm add react-hook-form zod@3.21.4 @hookform/resolvers
創建AuthForm並打開storybook:
pnpm exec nx generate @iron-ecommerce-org/plugins:component iron-components AuthForm
pnpm exec nx run iron-components:storybook
libs\iron-components\src\lib\AuthForm\AuthForm.stories.tsx
:
import { Meta, StoryObj } from "@storybook/react";
import AuthForm from "./AuthForm";
const meta: Meta<typeof AuthForm> = {
component: AuthForm
};
export default meta;
type Story = StoryObj<typeof AuthForm>;
export const Default: Story = {
args: {}
};
並且將先前在apps\iron-ecommerce-next\app\user\auth\userAuth.client.tsx
中創建的form抽取出來並放置在libs\iron-components\src\lib\AuthForm\AuthForm.tsx
:
import { Button, Text, TextField } from "@radix-ui/themes";
import { FormEventHandler } from "react";
/* eslint-disable-next-line */
export interface AuthFormProps {}
export function AuthForm(props: AuthFormProps) {
const handleSubmit: FormEventHandler = (event) => {
event.preventDefault();
console.log("Form submitted");
};
return (
<form onSubmit={handleSubmit} className="flex flex-direction:column gap:2">
<div className="flex flex-direction:column gap:1rem">
<TextField.Root>
<TextField.Slot>
<Text as="label">Email</Text>
</TextField.Slot>
<TextField.Input />
</TextField.Root>
<TextField.Root>
<TextField.Slot>
<Text as="label">Password</Text>
</TextField.Slot>
<TextField.Input />
</TextField.Root>
<Button type="submit">Login</Button>
</div>
</form>
);
}
export default AuthForm;
接下來我們加入zod和react-hook-form來修改我們的元件:
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Flex, Text, TextField } from "@radix-ui/themes";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
/* eslint-disable-next-line */
export interface AuthFormProps {}
const LoginSchema = z.object({
email: z.string().email("無效的電子郵件地址。"),
password: z.string().min(8, "密碼必須至少8個字符。")
});
const RegisterSchema = LoginSchema.extend({
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "密碼和確認密碼不匹配"
});
type LoginData = z.infer<typeof LoginSchema>;
type RegisterData = z.infer<typeof RegisterSchema>;
const AuthForm: React.FC<AuthFormProps> = () => {
const [isRegistering, setIsRegistering] = useState(false);
const {
register,
handleSubmit,
formState: { errors }
} = useForm<LoginData | RegisterData>({
resolver: zodResolver(isRegistering ? RegisterSchema : LoginSchema)
});
const onSubmit = (data: LoginData | RegisterData) => {
console.log("Form data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-direction:column gap:2">
<div className="flex flex-direction:column gap:1rem">
<TextField.Root>
<TextField.Slot>
<Text as="label">Email</Text>
</TextField.Slot>
<TextField.Input {...register("email")} />
</TextField.Root>
{typeof errors.email?.message === "string" && <Text color="red">{errors.email.message}</Text>}
<TextField.Root>
<TextField.Slot>
<Text as="label">Password</Text>
</TextField.Slot>
<TextField.Input {...register("password")} />
</TextField.Root>
{typeof errors.password?.message === "string" && <Text color="red">{errors.password?.message}</Text>}
{isRegistering && (
<>
<TextField.Root>
<TextField.Slot>
<Text as="label">確認密碼</Text>
</TextField.Slot>
<TextField.Input {...register("confirmPassword")} />
</TextField.Root>
{"confirmPassword" in errors && typeof errors.confirmPassword?.message === "string" && (
<Text color="red">{errors.confirmPassword?.message}</Text>
)}
</>
)}
<Flex justify="between">
<Button type="button" variant="ghost" onClick={() => setIsRegistering(!isRegistering)}>
{isRegistering ? "已有帳戶?點擊登入" : "沒有帳戶?點擊註冊"}
</Button>
<Button type="submit">{isRegistering ? "註冊" : "登入"}</Button>
</Flex>
</div>
</form>
);
}
export default AuthForm;
接著修改頁面上的auth form,打開apps\iron-ecommerce-next\app\user\auth\userAuth.client.tsx
"use client";
import { Box, Card, Flex } from "@radix-ui/themes";
import AuthForm from "libs/iron-components/src/lib/AuthForm";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface UserAuthProps {}
// eslint-disable-next-line no-empty-pattern
const UserAuthClient = ({}: UserAuthProps) => {
return (
<Flex align="center" justify="center">
<section className="flex flex:wrap flex-direction:row flex-basis:xs flex-basis:full@<xs gap:1rem jc:center">
<Box className="w:100% mt:1rem">
<Card>
<h1 className="text:2xl font:bold text:center mb:4">Login</h1>
<AuthForm />
</Card>
</Box>
</section>
</Flex>
);
};
export default UserAuthClient;
接下來我們要創建購物車表單。
打開libs\iron-components\src\lib\CartForm\CartForm.tsx
:
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Table, TextField } from "@radix-ui/themes";
import React from 'react';
import { useForm } from "react-hook-form";
import { z } from "zod";
interface Product {
id: number;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
type CartFormValues = Record<string, { quantity: string }>;
interface CartFormProps {
items: CartItem[];
}
const CartItemSchema = z.object({
quantity: z.string().regex(/^\d+$/, "必須是有效的正整數")
});
const generateCartSchema = (items: CartItem[]) => {
const schemaShape: Record<string, typeof CartItemSchema> = {};
items.forEach(item => {
schemaShape[item.id.toString()] = CartItemSchema;
});
return z.object(schemaShape);
};
const CartForm: React.FC<CartFormProps> = ({ items }) => {
const cartSchema = generateCartSchema(items);
const {
register,
handleSubmit,
watch,
formState: { errors }
} = useForm<CartFormValues>({
resolver: zodResolver(cartSchema)
});
const watchedValues = watch();
const calculateItemTotal = (id: number, price: number) => {
const quantity = parseInt(watchedValues[id]?.quantity || '0', 10);
return quantity * price;
};
const grandTotal = items.reduce(
(acc, item) => acc + calculateItemTotal(item.id, item.price),
0
);
const onSubmit = (data: CartFormValues) => {
console.log("Form data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>商品名稱</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>價格</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>數量</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>總計</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map(item => (
<Table.Row key={item.id}>
<Table.RowHeaderCell>{item.name}</Table.RowHeaderCell>
<Table.Cell>{item.price} 元</Table.Cell>
<Table.Cell>
<TextField.Input
{...register(`${item.id}.quantity`)}
defaultValue={item.quantity.toString()}
type="number"
/>
{typeof errors[item.id]?.quantity?.message === "string" && <span style={{color: 'red'}}>{errors[item.id]?.quantity?.message}</span>}
</Table.Cell>
<Table.Cell>{calculateItemTotal(item.id, item.price)} 元</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<div className='flex jc:space-between ai:center mt:5em'>
<span>總計:{grandTotal} 元</span>
<Button type="submit">更新購物車</Button>
</div>
</form>
);
}
export default CartForm;
創建並修改libs\iron-components\src\lib\CartForm\CartForm.stories.tsx
:
import { Meta, StoryObj } from "@storybook/react";
import CartForm from "./CartForm";
const meta: Meta<typeof CartForm> = {
component: CartForm
};
export default meta;
type Story = StoryObj<typeof CartForm>;
const fakeData = [
{
id: 1,
name: "商品A",
price: 100,
quantity: 1
},
{
id: 2,
name: "商品B",
price: 150,
quantity: 2
}
];
export const Default: Story = {
args: {
items: fakeData
}
};
將其加入到我們的Page,打開apps\iron-ecommerce-next\app\cart\cart.client.tsx
:
"use client";
import { Flex } from "@radix-ui/themes";
import CartForm from "libs/iron-components/src/lib/CartForm";
const fakeData = [
{
id: 1,
name: "商品A",
price: 100,
quantity: 1
},
{
id: 2,
name: "商品B",
price: 150,
quantity: 2
}
];
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CartProps {}
// eslint-disable-next-line no-empty-pattern
const CartClient = ({}: CartProps) => {
return (
<Flex align="center" justify="center">
<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center bg:red">
<CartForm items={fakeData} />
</section>
</Flex>
);
};
export default CartClient;
並且修正test,打開libs\iron-components\src\lib\CartForm\CartForm.spec.tsx
import { render } from "@testing-library/react";
import CartForm from "./CartForm";
describe("CartForm", () => {
it("should render successfully", () => {
const { baseElement } = render(<CartForm items={[]} />);
expect(baseElement).toBeTruthy();
});
});
接下來我們創建Order form
我們創建元件並打開libs\iron-components\src\lib\OrderForm\OrderForm.tsx
:
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Flex, Table, Text, TextField } from "@radix-ui/themes";
import React from 'react';
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
interface OrderFormItem {
id: string;
name: string;
quantity: number;
}
export interface OrderFormValues {
name: string;
email: string;
address: string;
items: OrderFormItem[];
}
interface OrderFormProps {
defaultValues?: Partial<OrderFormValues>;
onSubmit: (data: OrderFormValues) => void;
}
const OrderItemSchema = z.object({
id: z.string(),
name: z.string().nonempty("商品名稱不能為空"),
quantity: z.number().min(1, "數量至少為1")
});
const OrderSchema = z.object({
name: z.string().nonempty("名稱不能為空"),
email: z.string().email("無效的電子郵件地址"),
address: z.string().nonempty("地址不能為空"),
items: z.array(OrderItemSchema)
});
const OrderForm: React.FC<OrderFormProps> = ({ defaultValues, onSubmit }) => {
const { register, handleSubmit, control, formState: { errors } } = useForm<OrderFormValues>({
resolver: zodResolver(OrderSchema),
defaultValues
});
const { fields, append, remove } = useFieldArray({
control,
name: "items"
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Flex direction="column" gap="3">
<TextField.Root>
<TextField.Slot>
<Text as="label">名稱</Text>
</TextField.Slot>
<TextField.Input placeholder="名稱" {...register('name')} />
{errors.name && <span style={{color: 'red'}}>{errors.name.message}</span>}
</TextField.Root>
<TextField.Root>
<TextField.Slot>
<Text as="label">電子郵件</Text>
</TextField.Slot>
<TextField.Input placeholder="電子郵件" {...register('email')} />
{errors.email && <span style={{color: 'red'}}>{errors.email.message}</span>}
</TextField.Root>
<TextField.Root>
<TextField.Slot>
<Text as="label">地址</Text>
</TextField.Slot>
<TextField.Input placeholder="地址" {...register('address')} />
{errors.address && <span style={{color: 'red'}}>{errors.address.message}</span>}
</TextField.Root>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>商品名稱</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>數量</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{fields.map((item, index) => (
<Table.Row key={item.id}>
<Table.Cell>
<TextField.Input placeholder="商品名稱" {...register(`items.${index}.name`)} defaultValue={item.name} />
{errors.items && errors.items[index]?.name && <span style={{color: 'red'}}>{errors.items[index]?.name?.message}</span>}
</Table.Cell>
<Table.Cell>
<TextField.Input type="number" placeholder="數量" {...register(`items.${index}.quantity`)} defaultValue={item.quantity} />
{errors.items && errors.items[index]?.quantity && <span style={{color: 'red'}}>{errors.items[index]?.quantity?.message}</span>}
</Table.Cell>
<Table.Cell>
<Button onClick={() => remove(index)}>刪除</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<div className='flex jc:space-between ai:center mt:5em'>
<Button type="button" onClick={() => append({ id: Date.now().toString(), name: "", quantity: 1 })}>
新增商品
</Button>
<Button type="submit">提交訂單</Button>
</div>
</Flex>
</form>
);
}
export default OrderForm;
創建story並加入,打開`libs\iron-components\src\lib\OrderForm\OrderForm.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
import OrderForm from "./OrderForm";
const meta: Meta<typeof OrderForm> = {
component: OrderForm
};
export default meta;
type Story = StoryObj<typeof OrderForm>;
export const Default: Story = {
args: {
defaultValues: {
name: 'tester1',
email: 'test@test.com',
address: '123456, Taipei, Taiwan',
items: [
{
id: '1',
name: '商品A',
quantity: 2,
},
{
id: '2',
name: '商品B',
quantity: 1,
},
],
},
onSubmit: (data) => console.log('Submitted data:', data),
}
};
修改一下我們的test:
// libs\iron-components\src\lib\OrderForm\OrderForm.spec.tsx
import { render } from "@testing-library/react";
import OrderForm, { OrderFormValues } from "./OrderForm";
describe("OrderForm", () => {
it("should render successfully", () => {
const { baseElement } = render(
<OrderForm
onSubmit={function (data: OrderFormValues): void {
throw new Error("Function not implemented.");
}}
/>
);
expect(baseElement).toBeTruthy();
});
});
最後我們將該form加入到page中,打開:
// apps\iron-ecommerce-next\app\checkout\checkout.client.tsx
"use client";
import { Flex } from "@radix-ui/themes";
import OrderForm, { OrderFormValues } from "libs/iron-components/src/lib/OrderForm/OrderForm";
const fakeOrderValues: Partial<OrderFormValues> = {
name: "John Doe",
email: "john.doe@example.com",
address: "123 Example St.",
items: [
{
id: "1",
name: "商品A",
quantity: 2
},
{
id: "2",
name: "商品B",
quantity: 1
}
]
};
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CheckoutProps {}
// eslint-disable-next-line no-empty-pattern
const CheckoutClient = ({}: CheckoutProps) => {
const handleSubmit = (data: OrderFormValues) => {
console.log("Submitted Order:", data);
};
return (
<Flex align="center" justify="center">
<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center bg:red">
<OrderForm defaultValues={fakeOrderValues} onSubmit={handleSubmit} />
</section>
</Flex>
);
};
export default CheckoutClient;
本文展示了如何使用zod、react-hook-form來創建和管理現代web應用中的表單,並且如何驗證表單內容。