fix(admin) : clarify validation and normalize percent input
CI / Verify (push) Successful in 1m1s
CI / Build immutable images and deploy (push) Successful in 2m4s

This commit is contained in:
2026-09-02 21:21:03 +07:00 Unverified
parent c63b879ddb
commit 51e74eee04
7 changed files with 133 additions and 14 deletions
+4 -3
View File
@@ -1,7 +1,7 @@
import { AlertCircleIcon, CheckIcon, ClockIcon } from "lucide-react";
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -14,13 +14,14 @@ export function AutosaveStatus({ state, issues = [], onSave }: { state: Autosave
if (state === "unsaved") return <Badge variant="outline"><ClockIcon data-icon="inline-start" />Waiting to save…</Badge>;
if (state === "incomplete") return <div className="flex flex-col items-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowIssues((current) => !current)} aria-expanded={showIssues}>
<AlertCircleIcon data-icon="inline-start" />{showIssues ? "Hide incomplete fields" : "Check incomplete fields"}
<AlertCircleIcon data-icon="inline-start" />{showIssues ? "ซ่อนรายการ" : "ตรวจสอบข้อมูลที่ยังไม่ครบ"}
</Button>
{showIssues ? <Alert variant="destructive" className="max-w-md">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>กรุณากรอกข้อมูลให้ครบ</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
<p>Local draft ถูกเก็บไว้แล้ว และจะบันทึกอัตโนมัติเมื่อข้อมูลครบ</p>
<p>แก้ไขรายการด้านล่าง แล้วระบบจะบันทึกให้อัตโนมัติ ข้อมูลที่กรอกไว้ยังไม่หาย</p>
{issues.length ? <ul className="flex flex-col gap-1">{issues.map((issue, index) => <li key={`${issue}-${index}`}>{issue}</li>)}</ul> : null}
</div>
</AlertDescription>
+2 -2
View File
@@ -17,8 +17,8 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const value = { coverMediaId: cover.id, overview };
const incompleteIssues = [
!cover.id ? "Guide Image is required" : null,
!overview.trim() ? "Overview is required" : null,
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
!overview.trim() ? "กรุณากรอก Overview" : null,
].filter((issue): issue is string => Boolean(issue));
const localDraft = { overview, cover };
const autosave = useAutosave({
+11 -9
View File
@@ -93,6 +93,8 @@ import {
type TalentPriority,
type TalentRelation,
} from "@/lib/guides/talents";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
@@ -357,12 +359,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
<DndContext sensors={weaponSensors} collisionDetection={closestCenter} onDragEnd={handleWeaponDragEnd}>
<SortableContext items={rows.map((row) => row.clientId)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <SortableWeaponCard key={row.clientId} id={row.clientId}>{(dragHandle) => <Card size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><div className="flex gap-1">{dragHandle}<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon-sm" />} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></DeleteConfirmationDialog></div></CardAction></CardHeader><CardContent><FieldGroup><div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4"><Field className="min-w-0"><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} /></Field><Field className="shrink-0"><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field></div><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} /></Field></div><Field><FieldLabel htmlFor={`weapon-note-${row.clientId}`}>Note (optional)</FieldLabel><Textarea id={`weapon-note-${row.clientId}`} value={row.note} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, note: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>}</SortableWeaponCard>; })}
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <SortableWeaponCard key={row.clientId} id={row.clientId}>{(dragHandle) => <Card size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", rarityGradientClass(selectedWeapon.rarity))}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><div className="flex gap-1">{dragHandle}<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon-sm" />} title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`} description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide" onConfirm={() => setRows(rows.filter((item) => item.clientId !== row.clientId))}><Trash2Icon /><span className="sr-only">ลบอาวุธ</span></DeleteConfirmationDialog></div></CardAction></CardHeader><CardContent><FieldGroup><div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4"><Field className="min-w-0"><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" rarityBackground onChange={(weaponKey) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} /></Field><Field className="shrink-0"><FieldLabel>Refinement</FieldLabel><ToggleGroup variant="outline" value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field></div><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="text" inputMode="decimal" value={row.overallPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: normalizePercentInput(event.target.value, item.overallPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: finalizePercentInput(item.overallPercent) } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="text" inputMode="decimal" value={row.personalPercent} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: normalizePercentInput(event.target.value, item.personalPercent) } : item))} onBlur={() => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: finalizePercentInput(item.personalPercent) } : item))} /></Field></div><Field><FieldLabel htmlFor={`weapon-note-${row.clientId}`}>Note (optional)</FieldLabel><Textarea id={`weapon-note-${row.clientId}`} value={row.note} onChange={(event) => setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, note: event.target.value } : item))} /></Field></FieldGroup></CardContent></Card>}</SortableWeaponCard>; })}
</div>
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { clientId: createClientId("weapon"), weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "", note: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("weapon", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
@@ -461,7 +463,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
<CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground showSelectedImage onChange={(firstArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} />
<CatalogPicker items={artifacts} value={row.secondArtifactKey ?? ""} placeholder="ค้นหาชุดที่สอง (ถ้ามี)…" rarityBackground showSelectedImage onChange={(secondArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} />
<Input type="number" step="0.01" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
<Input type="text" inputMode="decimal" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent) } : candidate) } : item))} />
<DeleteConfirmationDialog trigger={<Button type="button" variant="ghost" size="icon" />} title={`ลบ Artifact อันดับ ${rowIndex + 1}?`} description={`รายการ Artifact ในกลุ่ม ${group.name} และเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide`} onConfirm={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></DeleteConfirmationDialog>
</div>
<Field><FieldLabel htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}>Note (optional)</FieldLabel><Textarea id={`artifact-note-${groupIndex}-${rowIndex}`} value={row.note ?? ""} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate) } : item))} /></Field>
@@ -552,7 +554,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
<CardFooter className="justify-end"><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("artifact", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
</Card>
</div>
);
@@ -594,12 +596,12 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa
<CardContent className="flex flex-col gap-4">
<Field><FieldLabel htmlFor={`constellation-condition-note-${group.clientId}`}>Condition note (optional)</FieldLabel><Textarea id={`constellation-condition-note-${group.clientId}`} value={group.note} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, note: event.target.value } : item))} /></Field>
<div className="grid gap-4 lg:grid-cols-2">
{group.rows.map((row, rowIndex) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" step="0.01" value={row.cumulativePercent} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: event.target.value } : candidate) } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
{group.rows.map((row, rowIndex) => <Card key={row.level} size="sm"><CardHeader>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</CardTitle><CardDescription>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="text" inputMode="decimal" value={row.cumulativePercent} onChange={(event) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: normalizePercentInput(event.target.value, candidate.cumulativePercent) } : candidate) } : item))} onBlur={() => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, cumulativePercent: finalizePercentInput(candidate.cumulativePercent) } : candidate) } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setGroups(groups.map((item) => item.clientId === group.clientId ? { ...item, rows: item.rows.map((candidate, index) => index === rowIndex ? { ...candidate, isGreat } : candidate) } : item))} aria-label={`Mark Condition ${groupIndex + 1} C${row.level} percentage as great`} className={cn(SELECTED_TOGGLE_CLASS, "data-[state=on]:text-warning")}><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
</div>
</CardContent>
</Card>)}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => setGroups([...groups, { clientId: createClientId("constellation-condition"), note: "", rows: makeRows() }])}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" disabled={groups.length >= 20} onClick={() => setGroups([...groups, { clientId: createClientId("constellation-condition"), note: "", rows: makeRows() }])}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("constellations", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
@@ -728,7 +730,7 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" disabled={characters.length < 4} onClick={() => setTeams([...teams, { clientId: createClientId("team"), title: "", rotationSeconds: "20", combo: "", members: fallbackMembers }])}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("team", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
@@ -809,7 +811,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</Field>
<Field><FieldLabel>หัวข้อ</FieldLabel><Input value={row.heading} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} /></Field>
<Field><FieldLabel>ข้อความ</FieldLabel><Textarea value={row.body} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body: event.target.value } : candidate) } : item))} /></Field>
<Field><FieldLabel>Percent (optional)</FieldLabel><Input type="number" step="0.01" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} /></Field>
<Field><FieldLabel>Percent (optional)</FieldLabel><Input type="text" inputMode="decimal" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: normalizePercentInput(event.target.value, candidate.percent ?? "") || null } : candidate) } : item))} onBlur={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: finalizePercentInput(candidate.percent ?? "") || null } : candidate) } : item))} /></Field>
</FieldGroup>
</CardContent>
</Card>
@@ -819,7 +821,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</Card>
))}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : validation.error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message)} onSave={autosave.saveNow} /></CardFooter>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave.state} issues={validation.success ? [] : formatGuideValidationIssues("extra", validation.error.issues)} onSave={autosave.saveNow} /></CardFooter>
</Card>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
describe("percentage input", () => {
it("turns only a lone dash into zero when editing finishes", () => {
expect(normalizePercentInput("-")).toBe("-");
expect(finalizePercentInput("-")).toBe("0");
expect(finalizePercentInput("-20")).toBe("-20");
});
it("keeps positive and negative decimal input and ignores other text", () => {
expect(normalizePercentInput("12.5")).toBe("12.5");
expect(normalizePercentInput("-12.5")).toBe("-12.5");
expect(normalizePercentInput("invalid", "12")).toBe("12");
});
});
+10
View File
@@ -0,0 +1,10 @@
const DECIMAL = /^-?(?:\d+\.?\d*|\.\d*)?$/u;
export function normalizePercentInput(nextValue: string, currentValue = ""): string {
const value = nextValue.trim();
return DECIMAL.test(value) ? value : currentValue;
}
export function finalizePercentInput(value: string): string {
return value === "-" ? "0" : value;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
describe("guide validation messages", () => {
it("turns a raw weapon path into an actionable message", () => {
expect(formatGuideValidationIssues("weapon", [{ path: ["rows", 5, "weaponKey"], message: "Invalid input" }])).toEqual([
"อาวุธอันดับ 6: กรุณาเลือกอาวุธ",
]);
});
it("keeps specific Thai schema messages with a useful item label", () => {
expect(formatGuideValidationIssues("team", [{ path: ["teams", 1, "members"], message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว" }])).toEqual([
"Team 2: Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
]);
});
});
+72
View File
@@ -0,0 +1,72 @@
export type GuideValidationSection = "weapon" | "artifact" | "constellations" | "team" | "extra";
interface ValidationIssue {
path: readonly PropertyKey[];
message: string;
}
const fieldMessages: Record<string, string> = {
weaponKey: "กรุณาเลือกอาวุธ",
firstArtifactKey: "กรุณาเลือก Artifact ชุดแรก",
characterKey: "กรุณาเลือกตัวละคร",
overallPercent: "กรุณากรอก Overall % ระหว่าง -9,999 ถึง 9,999",
personalPercent: "กรุณากรอก Personal % ระหว่าง -9,999 ถึง 9,999",
percent: "กรุณากรอกเปอร์เซ็นต์ระหว่าง -9,999 ถึง 9,999",
cumulativePercent: "กรุณากรอก Cumulative % ระหว่าง -9,999 ถึง 9,999",
title: "กรุณากรอกชื่อไม่เกิน 160 ตัวอักษร",
heading: "กรุณากรอกหัวข้อไม่เกิน 200 ตัวอักษร",
combo: "กรุณากรอก Combo",
rotationSeconds: "กรุณากรอก Rotation เป็นตัวเลขมากกว่า 0",
damage: "กรุณากรอก Damage เป็นตัวเลข 0 ขึ้นไป",
};
const sectionLabels: Record<GuideValidationSection, string> = {
weapon: "Weapon tier list",
artifact: "Artifact",
constellations: "Constellations",
team: "Team Calc",
extra: "Extra sections",
};
function indexAfter(path: readonly PropertyKey[], key: string): number | null {
const position = path.indexOf(key);
const value = path[position + 1];
return position >= 0 && typeof value === "number" ? value : null;
}
function issueContext(section: GuideValidationSection, path: readonly PropertyKey[]): string {
const rowIndex = indexAfter(path, "rows");
const groupIndex = indexAfter(path, "groups");
const teamIndex = indexAfter(path, "teams");
const memberIndex = indexAfter(path, "members");
const sectionIndex = indexAfter(path, "sections");
if (section === "weapon" && rowIndex !== null) return `อาวุธอันดับ ${rowIndex + 1}`;
if (section === "artifact" && groupIndex !== null) {
const group = groupIndex === 0 ? "Late Game" : "Early Game";
return rowIndex === null ? `Artifact ${group}` : `Artifact ${group} อันดับ ${rowIndex + 1}`;
}
if (section === "constellations" && groupIndex !== null) {
return rowIndex === null ? `Condition ${groupIndex + 1}` : `Condition ${groupIndex + 1} · C${rowIndex + 1}`;
}
if (section === "team" && teamIndex !== null) {
return memberIndex === null ? `Team ${teamIndex + 1}` : `Team ${teamIndex + 1} · ตัวละคร ${memberIndex + 1}`;
}
if (section === "extra" && sectionIndex !== null) {
return rowIndex === null ? `Extra section ${sectionIndex + 1}` : `Extra section ${sectionIndex + 1} · แถว ${rowIndex + 1}`;
}
return sectionLabels[section];
}
function issueMessage(issue: ValidationIssue): string {
const field = issue.path.at(-1);
if (typeof field === "string" && fieldMessages[field]) return fieldMessages[field];
if (/[฀-๿]/u.test(issue.message)) return issue.message;
if (issue.message.startsWith("Too small")) return "กรุณากรอกข้อมูลให้ครบ";
if (issue.message.startsWith("Too big")) return "ข้อมูลเกินจำนวนหรือความยาวที่กำหนด";
return "กรุณาตรวจสอบข้อมูลให้ถูกต้อง";
}
export function formatGuideValidationIssues(section: GuideValidationSection, issues: readonly ValidationIssue[]): string[] {
return issues.map((issue) => `${issueContext(section, issue.path)}: ${issueMessage(issue)}`);
}