feat(weapon) : share public cards with inline admin editing

This commit is contained in:
2026-10-14 23:50:14 +07:00 Unverified
parent 024553b516
commit 8ad431e68f
4 changed files with 351 additions and 335 deletions
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { act, createElement, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { WeaponComparisonCard } from "@/components/weapon-comparison-card";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useManualSave } from "@/hooks/use-manual-save";
import { WeaponEditor } from "./weapon-editor";
vi.mock("@/app/admin/actions", () => ({ saveWeaponSection: vi.fn() }));
vi.mock("@/hooks/use-manual-save", () => ({
useManualSave: vi.fn(() => ({ state: "saved", canSave: false })),
}));
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.clearAllMocks();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function render(value: string) {
const props = {
guide: { id: "guide", characterKey: "character", version: 1 },
characters: [{ key: "character", weaponType: "sword" }],
weapons: [{ key: "weapon", name: "Weapon", weaponType: "sword", rarity: 5, imageUrl: "/weapon.png" }],
initial: { sectionNote: null, weaponGroups: [{
id: "condition", name: null, note: null, rows: [{
id: "row", weaponKey: "weapon", refinement: "1", overallPercent: value, personalPercent: null, note: null,
}],
}] },
} as unknown as ComponentProps<typeof WeaponEditor>;
await act(async () => root.render(createElement(WeaponEditor, props)));
return container.querySelector<HTMLInputElement>("#weapon-overall-row")!;
}
describe("weapon damage editing", () => {
it("uses the public curve and damage typography with more room for inputs", async () => {
const input = await render("120.00");
const publicCard = document.createElement("div");
publicCard.innerHTML = renderToStaticMarkup(createElement(WeaponComparisonCard, {
weapon: { key: "weapon", name: "Weapon", rarity: 5, imageUrl: "/weapon.png" },
rank: 1, refinement: "1", note: null, overallPercent: "120.00", personalPercent: null,
weaponHref: "https://lunaris.moe/weapon/weapon",
}));
const publicContent = publicCard.querySelector('[data-slot="card-content"]')!;
const adminContent = input.closest('[data-slot="card-content"]')!;
expect(adminContent.className.replace(/md:grid-cols-\S+/u, ""))
.toBe(publicContent.className.replace(/md:grid-cols-\S+/u, ""));
expect(adminContent.className).toContain("md:grid-cols-[minmax(0,5fr)_minmax(0,2fr)]");
expect(adminContent.firstElementChild!.className.replace(" relative", ""))
.toBe(publicContent.firstElementChild!.className);
const publicValue = publicContent.lastElementChild!.querySelector("p")!;
expect(input.parentElement!.className).toBe(publicValue.className);
expect(input.value).toBe(publicValue.textContent);
expect(adminContent.lastElementChild!.className).toBe(publicContent.lastElementChild!.className);
});
it.each([
["0.00", "-"],
["120.00", "120%"],
["120.125", "120.13%"],
["", ""],
])("displays %s as %s while preserving its raw draft value", async (raw, display) => {
const input = await render(raw);
expect(input.value).toBe(display);
await act(async () => input.focus());
expect(input.value).toBe(raw);
await act(async () => input.blur());
expect(input.value).toBe(display);
const options = vi.mocked(useManualSave).mock.lastCall![0];
expect(options.value).toMatchObject({ groups: [{ rows: [{ overallPercent: raw === "" ? null : Number(raw) }] }] });
});
});
+145 -202
View File
@@ -1,7 +1,7 @@
"use client";
import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState, type ReactNode } from "react";
import { useState, type ComponentProps, type ReactNode } from "react";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -9,13 +9,14 @@ import { saveWeaponSection } from "@/app/admin/actions";
import { SaveStatus } from "@/components/admin/save-status";
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
import { WeaponSelect } from "@/components/catalog-select";
import { CatalogItemImage } from "@/components/catalog-item-image";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { WeaponComparisonCard, weaponComparisonPercent } from "@/components/weapon-comparison-card";
import { rarityColor } from "@/lib/catalog/rarity";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useManualSave } from "@/hooks/use-manual-save";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { weaponSectionSchema } from "@/lib/guides/schemas";
@@ -23,7 +24,29 @@ import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { finalizePercentInput, normalizePercentInput } from "@/lib/guides/percent-input";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
import { type EditorBase, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog, SELECTED_TOGGLE_CLASS } from "./shared";
import { type EditorBase, hasIssuePrefix, parseEditorDraft, ConditionControls, reorderConditions, createClientId, ConditionRenameDialog } from "./shared";
function WeaponDamageInput({ value, onFocus, onBlur, ...props }: ComponentProps<typeof Input> & { value: string }) {
const [focused, setFocused] = useState(false);
const numeric = Number(value);
const displayValue = !value.trim() || !Number.isFinite(numeric)
? value
: weaponComparisonPercent(value) ?? "";
return <Input
{...props}
value={focused ? value : displayValue}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
/>;
}
export function SortableWeaponCard({
@@ -157,16 +180,16 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
return (
<Card>
<CardHeader>
<CardTitle>Weapon tier list</CardTitle>
<CardTitle>จัดอันดับอาวุธ</CardTitle>
<CardDescription>
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง
กดปุ่มบันทึกเพื่อบันทึกการเปลี่ยนแปลง
ลากเพื่อเรียงอาวุธจากดีที่สุดลงมา กรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่องต่ออาวุธ
โดย 100% คือค่าฐานในการเปรียบเทียบ แล้วกดบันทึกเพื่อบันทึกการเปลี่ยนแปลง
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="weapon-section-note">
Header note (optional)
หมายเหตุส่วนอาวุธ (ไม่บังคับ)
</FieldLabel>
<Textarea
id="weapon-section-note"
@@ -225,7 +248,7 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
/>
<Field>
<FieldLabel htmlFor={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}>
Condition note (optional)
หมายเหตุเงื่อนไข (ไม่บังคับ)
</FieldLabel>
<Textarea
id={`weapon-condition-note-${groups[activeGroupIndex]?.clientId}`}
@@ -250,200 +273,120 @@ export function WeaponEditor({ guide, weapons, characters, initial }: EditorBase
return (
<SortableWeaponCard key={row.clientId} id={row.clientId}>
{(dragHandle) => (
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-3">
{selectedWeapon ? (
<CatalogItemImage item={selectedWeapon} kind="weapon" alt="" className="size-10 shrink-0 rounded-lg" sizes="40px" />
) : null}
<span>อันดับ {rowIndex + 1}</span>
</CardTitle>
<CardAction>
<div className="flex gap-1">
{dragHandle}
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="plain"
size="icon-sm"
/>
}
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
onConfirm={() =>
setRows(
rows.filter(
(item) => item.clientId !== row.clientId,
),
)
}
<WeaponComparisonCard
weapon={selectedWeapon ?? { key: row.weaponKey, name: "เลือกอาวุธ", imageUrl: null, rarity: 1 }}
rank={rowIndex + 1}
refinement={row.refinement}
note={row.note}
overallPercent={row.overallPercent}
personalPercent={row.personalPercent}
imageControl={
<Field className="w-20 shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
<WeaponSelect
presentation="slot"
className="size-20 border-0 bg-transparent"
label={`เลือกอาวุธอันดับ ${rowIndex + 1}`}
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground={false}
onValueChange={(weaponKey) => setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, weaponKey } : item,
))}
/>
</Field>
}
refinementControl={
<span className="inline-flex shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
<Select
value={row.refinement}
onValueChange={(refinement) => {
if (refinement) setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, refinement } : item,
));
}}
>
<SelectTrigger
className="border-white/30 text-white hover:border-white/60 [&_svg]:text-white"
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
size="sm" aria-label={`ระดับขัดเกลาอาวุธอันดับ ${rowIndex + 1}`} aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "refinement")}>
<SelectValue>{`R${row.refinement}`}</SelectValue>
</SelectTrigger>
<SelectContent
className="text-white [--color-accent:rgba(255,255,255,0.2)] [--color-accent-foreground:white]"
style={{ backgroundColor: rarityColor(selectedWeapon?.rarity ?? 1) }}
>
<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" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
<FieldLabel>อาวุธ</FieldLabel>
<WeaponSelect
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground
onValueChange={(weaponKey) =>
setRows(
rows.map((item) =>
item.clientId === row.clientId
? { ...item, weaponKey }
: item,
),
)
}
/>
</Field>
<Field className="shrink-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<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: next.slice(1),
}
: item,
),
);
}}
>
<SelectGroup>
{["1", "2", "3", "4", "5", "1-5"].map((refinement) => (
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
key={refinement}
value={`R${refinement}`}
>
R{refinement}
</ToggleGroupItem>
<SelectItem key={refinement} value={refinement}>R{refinement}</SelectItem>
))}
</ToggleGroup>
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Overall %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.overallPercent}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
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,
),
)
}
</SelectGroup>
</SelectContent>
</Select>
</span>
}
noteControl={
<Field>
<FieldLabel className="sr-only" htmlFor={`weapon-note-${row.clientId}`}>หมายเหตุอาวุธอันดับ {rowIndex + 1}</FieldLabel>
<Textarea
id={`weapon-note-${row.clientId}`}
rows={1}
className="min-h-8 bg-white/10! text-white placeholder:text-white/70"
placeholder="หมายเหตุอาวุธ (ไม่บังคับ)"
value={row.note}
onChange={(event) => setRows((current) => current.map((item) =>
item.clientId === row.clientId ? { ...item, note: event.target.value } : item,
))}
/>
</Field>
}
actions={
<div className="absolute top-2 right-2 flex gap-1">
{dragHandle}
<DeleteConfirmationDialog
trigger={
<Button
type="button"
variant="plain"
size="icon-sm"
/>
</Field>
<Field data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}>
<FieldLabel>Personal %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.personalPercent}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
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>
}
title={`ลบ ${selectedWeapon?.name ?? `อาวุธอันดับ ${rowIndex + 1}`}?`}
description="รายการอาวุธและเปอร์เซ็นต์เปรียบเทียบจะถูกลบออกจาก Guide"
onConfirm={() =>
setRows(
rows.filter(
(item) => item.clientId !== row.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบอาวุธ</span>
</DeleteConfirmationDialog>
</div>
}
renderMetric={(metric) => <WeaponDamageInput
className="h-[1em] rounded-none border-0 bg-transparent! p-0 text-center text-[length:inherit]! leading-none font-bold focus-visible:ring-2"
id={`weapon-${metric === "overallPercent" ? "overall" : "personal"}-${row.clientId}`}
aria-label={metric === "overallPercent" ? "ดาเมจทั้งทีม (%)" : "ดาเมจส่วนตัว (%)"}
type="text"
inputMode="decimal"
value={row[metric]}
aria-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex)}
onChange={(event) => setRows((current) => current.map((item) =>
item.clientId === row.clientId
? { ...item, [metric]: normalizePercentInput(event.target.value, item[metric]) }
: item,
))}
onBlur={() => setRows((current) => current.map((item) =>
item.clientId === row.clientId
? { ...item, [metric]: finalizePercentInput(item[metric]) }
: item,
))}
/>}
/>
)}
</SortableWeaponCard>
);