编辑表单
管理后台的“编辑商品”页面。从服务器读取当前内容填入表单,并保存修改的部分。
编辑商品
要点
- 1
加载完成之前,先显示占位形状
数据从服务器返回前的那 1 秒,显示灰色的占位形状(骨架屏)。如果显示空的输入框,用户会误以为“里面什么都没有”。
- 2
把返回的数据作为表单的初始值
把获取到的商品传给 useForm 的 values。数据一到,输入框就会被填好,“是否和初始值不同(isDirty)”也以它为基准来比较。
- 3
让用户知道内容有改动
修改后显示“有未保存的更改”。保存中禁用按钮并显示“保存中…”,完成后用通知告诉用户。
使用的工具
点击查看第 2 部分的比较
修改代码
修改代码,右边的预览会跟着变化。
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Skeleton } from "./components/ui/skeleton" import { Toaster } from "./components/ui/sonner" import { fetchProduct, saveProduct } from "./api" import { Form } from "lucide-react" // 保存已获取数据的容器(整个应用一个) const queryClient = new QueryClient() // 数字字段:输入框传来的是文字,先转成数字再校验(为空时提示“请输入…”) const numberField = (label) => z.preprocess( (value) => (value === "" ? undefined : Number(value)), z.number({ error: (issue) => (issue.input === undefined ? "请输入" + label : "请输入数字") }).int("请输入整数"), ) // 把数字字段里输入的文字转成数字再放进表单(为空时保持原样) // 不这样做的话,即使重新输入原来的 595,也会因为“"595"(文字)和 595(数字)不同”而被当作有改动 const toNumber = (value) => (value === "" ? "" : Number(value)) // 校验规则(Zod) const schema = z.object({ name: z.string().min(1, "请输入商品名称"), price: numberField("价格").pipe(z.number().min(1, "价格至少为 ¥1")), stock: numberField("库存").pipe(z.number().min(0, "不能小于 0")), description: z.string().max(200, "最多 200 个字符"), }) // 把标签、输入框和错误提示组合在一起 function Field({ id, label, error, children }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } // 加载中显示的灰色占位形状(骨架屏) function FormSkeleton() { return ( <div className="flex flex-col gap-5"> <Skeleton className="h-6 w-40" /> {[1, 2, 3].map((n) => ( <div key={n} className="flex flex-col gap-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-9 w-full" /> </div> ))} <Skeleton className="h-24 w-full" /> </div> ) } function EditProduct() { // 从服务器获取当前商品(获取期间 isPending 为 true) const { data, isPending } = useQuery({ queryKey: ["product"], queryFn: fetchProduct }) // 表单。把获取到的商品传给 values,数据到达时就会填入输入框 const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({ resolver: zodResolver(schema), values: data, }) // 保存(保存期间 mutation.isPending 为 true) const mutation = useMutation({ mutationFn: saveProduct, onSuccess: (saved) => { queryClient.setQueryData(["product"], saved) // 缓存中的商品也替换成保存后的数据 reset(saved) // 把保存后的值设为新的“初始值”(isDirty 变回 false) toast.success("已保存") }, }) // ① 加载中(isPending 为 true)时,先只返回 <FormSkeleton /> if (isPending) { return <FormSkeleton /> } return ( <form onSubmit={handleSubmit((values) => mutation.mutate(values))} className="flex flex-col gap-5"> <div className="flex items-center gap-3"> <h1 className="text-lg font-semibold">编辑商品</h1> {/* ② 只有在有改动时(isDirty 为 true)才显示下面的标签 */} {isDirty && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-700">有未保存的更改</span>} </div> <Field id="name" label="商品名称" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <div className="grid grid-cols-2 gap-4"> <Field id="price" label="价格(元)" error={errors.price?.message}> <Input id="price" inputMode="numeric" {...register("price", { setValueAs: toNumber })} /> </Field> <Field id="stock" label="库存" error={errors.stock?.message}> <Input id="stock" inputMode="numeric" {...register("stock", { setValueAs: toNumber })} /> </Field> </div> <Field id="description" label="商品描述" error={errors.description?.message}> <textarea id="description" rows={3} className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" {...register("description")} /> </Field> <div className="flex justify-end gap-2 border-t pt-4"> {/* ③ 没有改动或正在保存时不能点击(disabled) */} {/* ⑤ 点击后恢复为初始值(在 onClick 中调用 reset) */} <Button type="button" variant="outline" disabled={!isDirty || mutation.isPending} onClick={() => reset()}> 撤销更改 </Button> {/* ③ 没有改动或正在保存时不能点击(disabled) */} <Button type="submit" disabled={!isDirty || mutation.isPending}> {/* ④ 保存中(mutation.isPending 为 true)显示“保存中…”,否则显示“保存” */} {mutation.isPending ? "保存中…" : "保存"} </Button> </div> </form > ) } export default function App() { return ( // 用 QueryClientProvider 包裹整个应用,里面就能使用 useQuery <QueryClientProvider client={queryClient}> <div className="mx-auto my-6 w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground"> <EditProduct /> </div> <Toaster position="bottom-right" /> </QueryClientProvider> ) }