Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 40 additions & 223 deletions src/app/bottles/[id]/edit/edit-bottle-form.tsx
Original file line number Diff line number Diff line change
@@ -1,233 +1,50 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { bottleSchema, REGIONS } from "@/lib/schemas/bottle";
import type { Bottle } from "@/generated/prisma/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";

// 数値入力:空欄は「未入力」として undefined を渡す(zod 側で NAS/既定値の扱いを決める)。
const asOptionalNumber = (value: unknown) =>
value === "" || value == null ? undefined : Number(value);

// 国の「未選択」用センチネル。Radix の SelectItem は空文字値を禁止するため、
// 空でないダミー値を持たせ、選択時に null(=クリア)へ正規化する(送信データには出さない)。
const NONE = "__none__";
import { REGIONS } from "@/lib/schemas/bottle";
import { BottleForm } from "../../bottle-form";

// 編集用ラッパー:共有フォームに既存値・文言・送信処理(PATCH)を渡す。
export function EditBottleForm({ bottle }: { bottle: Bottle }) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(bottleSchema),
// 既存値で初期化。任意テキストの null はフォーム用に "" へ、
// 国は DB では string 型だが値は REGIONS のいずれか(未設定は undefined)。
defaultValues: {
name: bottle.name,
region: (bottle.region as (typeof REGIONS)[number] | null) ?? undefined,
subRegion: bottle.subRegion ?? "",
age: bottle.age ?? undefined,
caskType: bottle.caskType ?? "",
isLimited: bottle.isLimited,
quantity: bottle.quantity,
note: bottle.note ?? "",
},
});

const onSubmit = handleSubmit(async (data) => {
setServerError(null);
// 空欄の任意項目は明示 null で送る(undefined だと JSON から落ち、PATCH で「変更なし」=消せないため)。
const payload = {
...data,
region: data.region ?? null,
subRegion: data.subRegion ?? null,
age: data.age ?? null,
caskType: data.caskType ?? null,
note: data.note ?? null,
};
try {
const response = await fetch(`/api/bottles/${bottle.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
setServerError("更新に失敗しました。もう一度お試しください。");
return;
}
router.push(`/bottles/${bottle.id}`);
router.refresh();
} catch {
setServerError("更新に失敗しました。もう一度お試しください。");
}
});

return (
<form
onSubmit={onSubmit}
noValidate
// 自動補完抑止の保険(Chrome の住所サジェストはこれを無視するため、本対策は銘柄名・地域の属性側)。
autoComplete="off"
>
<FieldGroup>
{/*
銘柄名・地域は Chrome に氏名・住所と誤認され、autocomplete="off" だけでは
住所サジェストを抑止できない(Chrome は off を無視して name/id から用途を推測する)。
認識されない name/id に変えるのが確実な回避策だが、register は DOM の name 属性に
依存するため、この 2 フィールドだけ Controller で接続して属性を自由にしている。
*/}
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="bottle-name">銘柄名(必須)</FieldLabel>
<Controller
control={control}
name="name"
render={({ field }) => (
<Input
{...field}
id="bottle-name"
name="bottle-name"
autoComplete="off"
aria-invalid={!!errors.name}
/>
)}
/>
<FieldError errors={[errors.name]} />
</Field>

<Field>
<FieldLabel htmlFor="region">国</FieldLabel>
<Controller
control={control}
name="region"
render={({ field }) => (
<Select
// null のときは「未選択」項目(NONE)を選択状態にする(プレースホルダではなくチェックを付ける)。
value={field.value ?? NONE}
onValueChange={(value) =>
field.onChange(value === NONE ? null : value)
}
>
<SelectTrigger id="region">
<SelectValue placeholder="未選択" />
</SelectTrigger>
<SelectContent>
{/* 「未選択」で既存の国を消せる(センチネル→null に正規化)。 */}
<SelectItem value={NONE}>未選択</SelectItem>
{REGIONS.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>

<Field>
<FieldLabel htmlFor="bottle-subregion">地域</FieldLabel>
<Controller
control={control}
name="subRegion"
render={({ field }) => (
<Input
{...field}
value={field.value ?? ""}
id="bottle-subregion"
name="bottle-subregion"
autoComplete="off"
placeholder="アイラ、スペイサイド など"
/>
)}
/>
</Field>

<Field data-invalid={!!errors.age}>
<FieldLabel htmlFor="age">年数</FieldLabel>
<Input
id="age"
type="number"
min={1}
placeholder="空欄 = NAS"
aria-invalid={!!errors.age}
{...register("age", { setValueAs: asOptionalNumber })}
/>
<FieldError errors={[errors.age]} />
</Field>

<Field>
<FieldLabel htmlFor="caskType">樽</FieldLabel>
<Input
id="caskType"
placeholder="シェリー樽、バーボン樽 など"
{...register("caskType")}
/>
</Field>

<Field orientation="horizontal">
<Controller
control={control}
name="isLimited"
render={({ field }) => (
<Checkbox
id="isLimited"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<FieldLabel htmlFor="isLimited">限定版</FieldLabel>
</Field>

<Field data-invalid={!!errors.quantity}>
<FieldLabel htmlFor="quantity">本数</FieldLabel>
<Input
id="quantity"
type="number"
min={1}
aria-invalid={!!errors.quantity}
{...register("quantity", { setValueAs: asOptionalNumber })}
/>
<FieldError errors={[errors.quantity]} />
</Field>

<Field>
<FieldLabel htmlFor="note">メモ</FieldLabel>
<Textarea id="note" {...register("note")} />
</Field>

{serverError && (
<p role="alert" className="text-sm text-destructive">
{serverError}
</p>
)}

<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "更新中…" : "更新する"}
</Button>
</FieldGroup>
</form>
<BottleForm
// 既存値で初期化。任意テキストの null はフォーム用に "" へ、
// 国は DB では string 型だが値は REGIONS のいずれか(未設定は undefined)。
defaultValues={{
name: bottle.name,
region: (bottle.region as (typeof REGIONS)[number] | null) ?? undefined,
subRegion: bottle.subRegion ?? "",
age: bottle.age ?? undefined,
caskType: bottle.caskType ?? "",
isLimited: bottle.isLimited,
quantity: bottle.quantity,
note: bottle.note ?? "",
}}
submitLabel="更新する"
submittingLabel="更新中…"
errorLabel="更新に失敗しました。もう一度お試しください。"
onSubmit={async (data) => {
// 空欄の任意項目は明示 null で送る(undefined だと JSON から落ち、PATCH で「変更なし」=消せないため)。
const payload = {
...data,
region: data.region ?? null,
subRegion: data.subRegion ?? null,
age: data.age ?? null,
caskType: data.caskType ?? null,
note: data.note ?? null,
};
const response = await fetch(`/api/bottles/${bottle.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return false;
router.push(`/bottles/${bottle.id}`);
router.refresh();
return true;
}}
/>
);
}
Loading
Loading