編輯個人資料
修改已經填好目前資料的表單並儲存。包含選擇圖片、顯示字數,以及儲存後的通知。
編輯個人資料
重點
- 1
一開始就填好目前的值
把目前的個人資料傳給 defaultValues。在真正的網站中,這裡放的是從伺服器取得的值。
- 2
什麼都沒改,就不能儲存
依照 isDirty(是否和初始值不同)讓儲存按鈕無法點擊。儲存後呼叫 reset,把儲存的值設成新的初始值。
- 3
儲存成功後,用通知告訴使用者
在畫面右下角顯示「已儲存」(sonner 的 toast)。畫面不會切換的操作,沒有通知的話,使用者就不知道有沒有儲存成功。
使用的工具
點擊查看第 2 部分的比較
修改程式碼
修改程式碼,右邊的預覽會跟著變化。
import { useState } from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Toaster } from "./components/ui/sonner" // ① 驗證規則(Zod) const schema = z.object({ name: z.string().min(1, "請輸入姓名"), username: z.string().regex(/^[a-z0-9_]+$/, "只能使用小寫英文字母、數字和 _"), bio: z.string().max(160, "最多 160 個字元"), }) type Profile = z.infer<typeof schema> // 目前的個人檔案(實際專案會從伺服器取得) const current: Profile = { name: "陳怡君", username: "yijun", bio: "正在學習 React。", } // 代替真正的伺服器:稍等一下 const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // 把標籤、輸入欄位和錯誤訊息組合在一起 function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: React.ReactNode }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } export default function App() { // 選擇的圖片(還沒選時為 null) const [avatarUrl, setAvatarUrl] = useState<string | null>(null) // ② defaultValues:一開始就填入目前的值 // isDirty:只要有一個值和初始值不同就是 true(沒有變更時無法儲存) const { register, handleSubmit, watch, reset, formState: { errors, isDirty, isSubmitting } } = useForm<Profile>({ resolver: zodResolver(schema), defaultValues: current, }) // 輸入欄位目前的值(每次輸入都會改變) const name = watch("name") const bio = watch("bio") async function onSubmit(values: Profile) { await wait(800) // 把儲存後的值設為新的「初始值」(isDirty 變回 false) reset(values) // ③ 儲存成功後,在右下角跳出通知(sonner) toast.success("已儲存") } // 選好圖片後顯示為大頭貼(只在瀏覽器裡預覽,不會上傳) function onPickImage(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (file) setAvatarUrl(URL.createObjectURL(file)) } return ( <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6"> <p className="text-lg font-semibold">個人檔案</p> <p className="mt-1 text-sm text-muted-foreground">這些資訊其他人看得到</p> <form onSubmit={handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4"> <div className="flex items-center gap-4"> <Avatar className="size-16"> {avatarUrl && <AvatarImage src={avatarUrl} alt="" />} <AvatarFallback className="bg-linear-to-br from-sky-400 to-indigo-500 text-xl font-semibold text-white">{name.slice(0, 1)}</AvatarFallback> </Avatar> <div> {/* 用按鈕樣式的 label 來點擊隱藏的檔案選擇欄位 */} <Button type="button" variant="outline" size="sm" asChild> <label htmlFor="avatar" className="cursor-pointer">更換大頭貼</label> </Button> <input id="avatar" type="file" accept="image/*" className="hidden" onChange={onPickImage} /> <p className="mt-1 text-xs text-muted-foreground">JPG 或 PNG,最大 2MB</p> </div> </div> <Field id="name" label="姓名" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <Field id="username" label="使用者名稱" error={errors.username?.message}> {/* 左側帶「@」的輸入欄位 */} <div className="flex rounded-md border"> <span className="flex items-center border-r bg-muted px-3 text-sm text-muted-foreground">@</span> <Input id="username" className="rounded-l-none border-0 shadow-none" {...register("username")} /> </div> </Field> <Field id="bio" label="自我介紹" error={errors.bio?.message}> <textarea id="bio" rows={3} className="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50" {...register("bio")} /> <p className="text-right text-xs text-muted-foreground">{bio.length} / 160</p> </Field> <div className="flex justify-end gap-2"> <Button type="button" variant="outline" disabled={!isDirty} onClick={() => reset()}> 還原 </Button> <Button type="submit" disabled={!isDirty || isSubmitting}> {isSubmitting ? "儲存中…" : "儲存"} </Button> </div> </form> <Toaster position="bottom-right" /> </div> ) }