編輯個人資料

修改已經填好目前資料的表單並儲存。包含選擇圖片、顯示字數,以及儲存後的通知。

編輯個人資料

重點

  1. 1

    一開始就填好目前的值

    把目前的個人資料傳給 defaultValues。在真正的網站中,這裡放的是從伺服器取得的值。

  2. 2

    什麼都沒改,就不能儲存

    依照 isDirty(是否和初始值不同)讓儲存按鈕無法點擊。儲存後呼叫 reset,把儲存的值設成新的初始值。

  3. 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>
  )
}