Next.js
69-实战篇 t3-app 实战 创建清单

前言

本篇我们将实现清单的增删查功能。

那就让我们接着开始吧!

1. 功能:创建清单

实现前端校验(RHF + Zod)

表单处理使用 React Hook Form 和 Zod。安装依赖项:

npm i react-hook-form zod @hookform/resolvers

新建 src/lib/const.ts,代码如下:

export const ListMap = new Map([
  ["rose", ["bg-rose-500", "玫瑰"]],
  ["amber", ["bg-amber-500", "琥珀"]],
  ["orange", ["bg-orange-500", "橘橙"]],
  ["green", ["bg-lime-500", "草绿"]],
  ["cyan", ["bg-sky-500", "天蓝"]],
  ["indigo", ["bg-indigo-500", "葡紫"]],
  ["pink", ["bg-pink-500", "粉粉"]],
  ["black", ["bg-black", "黑色"]],
]);

在这段代码中,使用 Map 建立颜色与色值、中文名的映射关系。

新建 src/schema/createList.ts,代码如下:

import { z } from "zod";
import { ListMap } from "@/lib/const";
 
export const createListZodSchema = z.object({
  name: z.string().min(1, {
    message: "名称不能为空",
  }),
  color: z
    .string()
    .min(1, {
      message: "请选择一个颜色",
    })
    .refine((color) => [...ListMap.keys()].includes(color)),
});
 
export type createListZodSchemaType = z.infer<typeof createListZodSchema>;

修改 src/components/CreateListModal.tsx,完整代码如下:

"use client";
 
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
 
import {
  createListZodSchema,
  type createListZodSchemaType,
} from "@/schema/createList";
import { cn } from "@/lib/utils";
import { ListMap } from "@/lib/const";
import { useState } from "react";
 
export default function CreateListModal() {
  const form = useForm({
    resolver: zodResolver(createListZodSchema),
    defaultValues: {
      name: "",
      color: "",
    },
  });
 
  const [open, setOpen] = useState(false);
 
  const onSubmit = async (data: createListZodSchemaType) => {
    console.log(data);
    setOpen(false);
  };
 
  const onOpenChange = (open: boolean) => {
    form.reset();
    setOpen(open);
  };
 
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetTrigger asChild>
        <Button>添加清单</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>添加清单</SheetTitle>
          <SheetDescription>
            清单是任务的集合,比如“工作”、“生活”、“副业”
          </SheetDescription>
        </SheetHeader>
        <Form {...form}>
          <form
            onSubmit={form.handleSubmit(onSubmit)}
            className="space-y-8 p-4"
          >
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>设置清单的名称:</FormLabel>
                  <FormControl>
                    <Input placeholder="例如:工作" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="color"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>选择清单的背景色:</FormLabel>
                  <FormControl>
                    <Select onValueChange={(color) => field.onChange(color)}>
                      <SelectTrigger
                        className={cn("w-[180px]", ListMap.get(field.value), {
                          "text-white": !!field.value,
                        })}
                      >
                        <SelectValue placeholder="颜色" />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectGroup>
                          {[...ListMap.entries()].map(
                            ([color, [className, name]]) => {
                              return (
                                <SelectItem
                                  key={color}
                                  value={color}
                                  className={cn(
                                    "my-1 w-full rounded-md text-white ring-black focus:font-bold focus:text-white focus:ring-2 dark:ring-white",
                                    className,
                                    `focus:${className}`,
                                  )}
                                >
                                  {name}
                                </SelectItem>
                              );
                            },
                          )}
                        </SelectGroup>
                      </SelectContent>
                    </Select>
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </form>
        </Form>
        <SheetFooter>
          <Button
            onClick={form.handleSubmit(onSubmit)}
            className="w-full"
            disabled={form.formState.isSubmitting}
          >
            创建
          </Button>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}

在这段代码中 ,有几点需要注意:

  1. 我们使用 React Hook Form 的 useForm 和 zodResolver 完成了前端数据校验
  2. Shadcn UI 虽然提供了 <SheetTrigger><SheetClose>用于控制 Sheet 的打开和关闭,但是当用户提交数据,验证通过的时候才能关闭 Sheet,所以需要改为手动控制:
const [open, setOpen] = useState(false);
 
return <Sheet open={open} onOpenChange={setOpen} />

注意:这些属性虽然在 Sheet 的文档 (opens in a new tab)里找不到说明,但可以找到对应的 Radix 组件文档 (opens in a new tab)链接。

此时浏览器效果如下:

t3-8.gif

效果描述:当点击“添加清单”按钮的时候,右侧弹出创建清单表单。当提交的时候,触发前端校验,数据校验通过后,浏览器命令行中打印了提交的数据,清单表单关闭。

实现服务端逻辑(Server Actions)

新建 src/actions/list.ts,代码如下:

"use server";
 
import {
  createListZodSchema,
  type createListZodSchemaType,
} from "@/schema/createList";
import { currentUser } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
 
export async function createList(data: createListZodSchemaType) {
  const user = await currentUser();
 
  if (!user) {
    throw new Error("用户未登录,请先登录");
  }
 
  const result = createListZodSchema.safeParse(data);
 
  if (!result.success) {
    return {
      success: false,
      message: result.error.flatten().fieldErrors,
    };
  }
 
  // Todo: 数据库处理
  console.log(data);
 
  revalidatePath("/");
 
  return {
    success: true,
    message: "清单创建成功",
  };
}

修改 src/components/CreateListModal.tsx,代码如下:

"use client";
 
// ...
import { createList } from "@/actions/list";
 
export default function CreateListModal() {
  // ...
 
  const onSubmit = async (data: createListZodSchemaType) => {
    try {
      await createList(data);
      onOpenChange(false);
      console.log("success");
    } catch (e) {
      console.log(e);
    }
  };
 
  // ...
 
  return (
  // ...
  );
}

浏览器效果如下:

t3-8.gif

效果描述:当提交数据的时候,会触发请求,成功后,浏览器控制台打印了 success

添加 toast 效果(Shadcn UI)

当创建的时候,无论成功还是失败,都应该有一个消息提示,所以我们引入 Shadcn UI 的 <Toaster> 组件。

修改 src/app/layout.tsx,添加代码如下:

// ...
import { Toaster } from "@/components/ui/toaster";
 
export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const localization = merge(zhCN, zhCNlocales);
  return (
    <ClerkProvider localization={localization}>
      <html
        lang="zh-CN"
        className={`${GeistSans.variable}`}
        suppressHydrationWarning
        >
        <body>
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
            >
            <Header />
            <div className="flex w-full flex-col items-center">{children}</div>
            <Toaster />
          </ThemeProvider>
        </body>
      </html>
    </ClerkProvider>
  );
}
 

修改 src/components/CreateListModal.tsx,代码如下:

"use client";
 
// ...
import { toast } from "@/components/ui/use-toast";
 
export default function CreateListModal() {
  // ...
 
  const onSubmit = async (data: createListZodSchemaType) => {
    try {
      await createList(data);
      onOpenChange(false);
      toast({
        title: "恭喜您",
        description: "清单创建成功!",
      });
    } catch (e) {
      console.log(e);
      toast({
        title: "哎呦",
        description: "清单创建失败",
        variant: "destructive",
      });
    }
  };
 
  // ...
 
  return (
  // ...
  );
}

此时浏览器效果如下:

t3-10.gif

效果描述:当创建成功的时候,会有一个 Toast 提示。

但是吧:

  1. Toast 从下面弹出,如果是从上面弹出就好了……
  2. Toast 停留的时间太久了,如果时间短点就好了……

这就要说到 Shadcn UI 的好处了,那就是可以直接改组件源码。

修改 src/components/ui/toast.tsx 第 19 行左右位置的样式代码,修改代码如下:

- fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:bottom-0 sm:right-0 sm:flex-col md:max-w-[420px]
+ fixed top-0 left-[50%] z-[100] flex max-h-screen w-full translate-x-[-50%] flex-col-reverse p-4 sm:right-0 sm:flex-col md:max-w-[420px]

修改 src/components/ui/toaster.tsx,添加代码如下:

// ...
 
export function Toaster() {
  // ...
 
  return (
    <ToastProvider duration={1000}>
      // ...
    </ToastProvider>
  );
}
 

此时浏览器效果如下:

t3-11.gif

Prisma 与数据库操作(Prisma)

新建 src/lib/prisma.ts,代码如下:

// 代码来源于官方文档:https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices
 
import { PrismaClient } from "@prisma/client";
 
const prismaClientSingleton = () => {
  return new PrismaClient();
};
 
declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
 
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
 
export default prisma;
 
if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

修改 prisma/schema.prisma,代码如下:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "mysql"
    url      = env("DATABASE_URL")
}

model List {
    id        Int      @id @default(autoincrement())
    name      String
    userId    String
    color     String
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}

因为修改了 Schema,所以运行:

npx prisma migrate dev

修改 src/actions/list.ts,完整代码如下:

"use server";
 
import {
  createListZodSchema,
  type createListZodSchemaType,
} from "@/schema/createList";
import { currentUser } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
 
export async function createList(data: createListZodSchemaType) {
  const user = await currentUser();
 
  if (!user) {
    throw new Error("用户未登录,请先登录");
  }
 
  const result = createListZodSchema.safeParse(data);
 
  if (!result.success) {
    return {
      success: false,
      message: result.error.flatten().fieldErrors,
    };
  }
 
  await prisma.list.create({
    data: {
      userId: user.id,
      color: data.color,
      name: data.name,
    },
  });
 
  revalidatePath("/");
 
  return {
    success: true,
    message: "清单创建成功",
  };
}

为了检查是否真的写入数据库,运行:

npx prisma studio

浏览器效果如下:

t3-12.gif

效果描述:清单创建成功后,数据库的数据也确实发生了更新

2. 功能:展示清单

修改 src/app/page.tsx,添加代码如下:

// ...
import { CheckLists } from "@/components/CheckLists";
 
// ...
 
export default function HomePage() {
  return (
    <main className="flex w-full flex-col items-center px-4">
      <Suspense fallback={<WelcomeFallback />}>
        <Welcome />
      </Suspense>
      <Suspense fallback={<WelcomeFallback />}>
        <CheckLists />
      </Suspense>
    </main>
  );
}
 

新建 src/components/CheckLists.tsx,代码如下:

import prisma from "@/lib/prisma";
import { currentUser } from "@clerk/nextjs/server";
import { type List } from "@prisma/client";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import CheckListFooter from "@/components/CheckListFooter";
 
import { cn } from "@/lib/utils";
import { ListMap } from "@/lib/const";
 
interface Props {
  checkList: List;
}
 
function CheckList({ checkList }: Props) {
  const { name, color } = checkList;
 
  return (
    <Card
      className={cn("w-full text-white sm:col-span-2", ListMap.get(color))}
      x-chunk="dashboard-05-chunk-0"
      >
      <CardHeader>
        <CardTitle>{name}</CardTitle>
      </CardHeader>
      <CardContent>
        <p>任务列表</p>
      </CardContent>
      <CardFooter className="flex-col pb-2">
        <CheckListFooter checkList={checkList} />
      </CardFooter>
    </Card>
  );
}
 
export async function CheckLists() {
  const user = await currentUser();
  const checkLists = await prisma.list.findMany({
    where: {
      userId: user?.id,
    },
  });
 
  if (checkLists.length === 0) {
    return <div className="mt-4">尚未创建清单,赶紧创建一个吧!</div>;
  }
 
  return (
    <>
      <div className="mt-6 flex w-full flex-col gap-4">
        {checkLists.map((checkList) => (
      <CheckList key={checkList.id} checkList={checkList} />
    ))}
      </div>
    </>
  );
}

新建 src/components/CheckListFooter.tsx,代码如下:

"use client";
 
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Trash2, CirclePlus } from "lucide-react";
import { type List } from "@prisma/client";
 
interface Props {
  checkList: List;
}
 
export default function CheckListFooter({ checkList }: Props) {
  const { createdAt } = checkList;
 
  const deleteCheckList = async () => {
    console.log(1);
  };
 
  return (
    <>
      <Separator />
      <footer className="flex h-[60px] w-full items-center justify-between text-sm text-white">
        <p>创建于 {createdAt.toLocaleDateString("zh-CN")}</p>
        <div>
          <Button size={"icon"} variant={"ghost"}>
            <CirclePlus />
          </Button>
          <AlertDialog>
            <AlertDialogTrigger asChild>
              <Button size={"icon"} variant={"ghost"}>
                <Trash2 />
              </Button>
            </AlertDialogTrigger>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>确定要删除吗?</AlertDialogTitle>
                <AlertDialogDescription>该操作无法撤回</AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel>取消</AlertDialogCancel>
                <AlertDialogAction onClick={deleteCheckList}>
                  确定
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </AlertDialog>
        </div>
      </footer>
    </>
  );
}
 

浏览器效果如下:

t3-13.gif

效果描述:当创建清单后,首页会刷新出新建的任务列表,点击删除按钮,会出现删除弹窗。

3. 功能:删除清单

修改 src/actions/list.ts,添加代码如下:

export async function deleteList(id: number) {
  const user = await currentUser();
  if (!user) {
    throw new Error("用户未登录,请先登录");
  }
 
  await prisma.list.delete({
    where: {
      id: id,
      userId: user.id,
    },
  });
 
  revalidatePath("/");
}

修改 src/components/CheckListFooter.tsx,完整代码如下:

"use client";
 
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Trash2, CirclePlus } from "lucide-react";
import { toast } from "@/components/ui/use-toast";
import { type List } from "@prisma/client";
import { deleteList } from "@/actions/list";
 
interface Props {
  checkList: List;
}
 
export default function CheckListFooter({ checkList }: Props) {
  const { id, createdAt } = checkList;
 
  const deleteCheckList = async () => {
    try {
      await deleteList(id);
      toast({
        title: "操作成功",
        description: "清单已经删除",
      });
    } catch (e) {
      toast({
        title: "操作失败",
        description: "清单删除失败,请稍后重试",
        variant: "destructive",
      });
    }
  };
 
  return (
    <>
      <Separator />
      <footer className="flex h-[60px] w-full items-center justify-between text-sm text-white">
        <p>创建于 {createdAt.toLocaleDateString("zh-CN")}</p>
        <div>
          <Button size={"icon"} variant={"ghost"}>
            <CirclePlus />
          </Button>
          <AlertDialog>
            <AlertDialogTrigger asChild>
              <Button size={"icon"} variant={"ghost"}>
                <Trash2 />
              </Button>
            </AlertDialogTrigger>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>确定要删除吗?</AlertDialogTitle>
                <AlertDialogDescription>该操作无法撤回</AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel>取消</AlertDialogCancel>
                <AlertDialogAction onClick={deleteCheckList}>
                  确定
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </AlertDialog>
        </div>
      </footer>
    </>
  );
}
 

浏览器效果如下:

t3-13.gif

下一篇

  1. 功能实现:t3-app 清单增删查
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-t3-todo-2 (opens in a new tab)
  3. 下载代码:git clone -b next-t3-todo-2 git@github.com:mqyqingfeng/next-app-demo.git

目前我们已经实现了清单的增删查,接下来我们实现清单中的具体任务的增改功能。