iT邦幫忙

2023 iThome 鐵人賽

DAY 13
0
Modern Web

由前向後,從前端邁向全端系列 第 13

13.【從前端到全端,Nextjs+Nestjs】創建商店頁面-建立表單

  • 分享至 

  • xImage
  •  

文章重點

  • 建立不同的表單,如登入表單、結帳表單和購物車表單
  • 使用 react-hook-formzod 建立表單和進行表單驗證

本文

今天,我們著重於創建不同類型的表單,涵蓋了登入表單、結帳表單以及購物車表單。

首先,我們進行了必要的設置,來安裝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;

https://ithelp.ithome.com.tw/upload/images/20230929/20108931p2ocVbeNr4.png

接著修改頁面上的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
	}
};

https://ithelp.ithome.com.tw/upload/images/20230929/20108931MRKFsYtDgx.png

將其加入到我們的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),
	}
};

https://ithelp.ithome.com.tw/upload/images/20230929/20108931ng75o1lng9.png

修改一下我們的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應用中的表單,並且如何驗證表單內容。


上一篇
12.【從前端到全端,Nextjs+Nestjs】創建商店頁面-實現頁面和元件區塊
下一篇
14.【從前端到全端,Nextjs+Nestjs】創建商店頁面-加入狀態管理
系列文
由前向後,從前端邁向全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
DL
iT邦新手 5 級 ‧ 2024-02-18 13:15:28

~

我要留言

立即登入留言