- ระบบหยุด Autosave ไว้เพื่อไม่ให้ Draft นี้เขียนทับข้อมูลล่าสุด
+ กรุณาเลือกโหลดข้อมูลล่าสุดหรือเขียนทับด้วย Draft นี้
{conflictVersion ? ` (version ${conflictVersion})` : ""}
@@ -123,13 +125,8 @@ export function AutosaveStatus({
- บันทึกอัตโนมัติไม่สำเร็จ
+ บันทึกไม่สำเร็จ
- {onSave ? (
-
- ) : null}
);
return (
@@ -139,3 +136,29 @@ export function AutosaveStatus({
);
}
+
+export function SaveStatus({
+ canSave,
+ onSave,
+ ...props
+}: SaveFeedbackProps & { canSave: boolean; onSave: () => void }) {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/admin/stygian-editor.test.ts b/components/admin/stygian-editor.test.ts
new file mode 100644
index 0000000..9d934c0
--- /dev/null
+++ b/components/admin/stygian-editor.test.ts
@@ -0,0 +1,87 @@
+// @vitest-environment happy-dom
+import { act, createElement, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({ save: vi.fn() }));
+vi.mock("@/app/admin/stygian/actions", () => ({ saveStygianRecommendationsAction: mocks.save }));
+vi.mock("@/components/admin/stygian-sync-button", () => ({ StygianSyncButton: () => null }));
+vi.mock("@/components/ui/resilient-image", () => ({ default: () => null }));
+vi.mock("@/components/stygian/stygian-boss-display", () => ({
+ StygianBossCard: ({ children }: { children: ReactNode }) => createElement("section", {}, children),
+ StygianBossTab: ({ boss }: { boss?: { name: string } }) => createElement("span", {}, boss?.name),
+}));
+vi.mock("@/components/admin/ui/catalog-select", () => ({
+ CharacterSelect: ({ onValueChange }: { onValueChange: (key: string) => void }) =>
+ createElement("button", { type: "button", onClick: () => onValueChange("test-character") }, "Choose test character"),
+ ArtifactSelect: () => null,
+ CatalogDropdown: () => null,
+ WeaponSelect: () => null,
+}));
+import { StygianEditor } from "./stygian-editor";
+
+let root: Root;
+let container: HTMLDivElement;
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.clearAllMocks();
+ mocks.save.mockImplementation(async (_scheduleId, input) => ({ status: "saved", version: input.expectedVersion + 1 }));
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+function activePanel() {
+ return Array.from(container.querySelectorAll
('[role="tabpanel"]')).find((panel) =>
+ !panel.closest('[hidden], [inert]') && panel.querySelector(":scope > section"),
+ )!;
+}
+async function clickButton(label: string, within: HTMLElement = activePanel()) {
+ const button = Array.from(within.querySelectorAll("button")).find((button) => button.textContent === label)!;
+ expect(button).toBeDefined();
+ await act(async () => button.click());
+}
+async function selectBoss(position: number) {
+ const tabs = Array.from(container.querySelectorAll('[role="tab"]')).filter((tab) =>
+ tab.getAttribute("title")?.startsWith("Test boss") && !tab.closest('[hidden]'),
+ );
+ await act(async () => tabs[position].click());
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+}
+
+describe("Stygian manual saving", () => {
+ it("keeps drafts across boss tabs and saves only the active tab on a click", async () => {
+ const bosses = [0, 1, 2].map((position) => ({
+ level: 105, position, name: `Test boss ${position}`, imageUrl: "/test.png", hp: 1,
+ description: "", mechanics: [], recommendedMechanics: [], recommendedMechanicTypes: [],
+ }));
+ await act(async () => root.render(createElement(StygianEditor, {
+ schedule: { scheduleId: 1, challengeName: "Test", version: 1 }, schedules: [], bosses,
+ recommendations: [], characters: [], weapons: [], artifacts: [],
+ })));
+ expect(container.querySelectorAll("section")).toHaveLength(1);
+ await clickButton("Add team");
+ await clickButton("Choose test character");
+ await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
+ expect(mocks.save).not.toHaveBeenCalled();
+ await selectBoss(1);
+ await clickButton("Add team");
+ await clickButton("Choose test character");
+ expect(container.querySelectorAll(".fixed")).toHaveLength(1);
+ await clickButton("บันทึก");
+ expect(mocks.save).toHaveBeenCalledTimes(1);
+ expect(mocks.save.mock.calls[0][1]).toMatchObject({ level: 105, bossPosition: 1, expectedVersion: 1 });
+ await selectBoss(0);
+ expect(activePanel().querySelector(".fixed button")?.hasAttribute("disabled")).toBe(false);
+ await clickButton("บันทึก");
+ expect(mocks.save).toHaveBeenCalledTimes(2);
+ expect(mocks.save.mock.calls[1][1]).toMatchObject({ level: 105, bossPosition: 0, expectedVersion: 2 });
+ expect(mocks.save.mock.calls[1][1].teams[0][0].catalogKey).toBe("test-character");
+ });
+});
diff --git a/components/admin/stygian-editor.tsx b/components/admin/stygian-editor.tsx
index 9deace3..437775b 100644
--- a/components/admin/stygian-editor.tsx
+++ b/components/admin/stygian-editor.tsx
@@ -2,7 +2,7 @@
import Image from "@/components/ui/resilient-image";
import Link from "next/link";
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
CopyIcon,
GripVerticalIcon,
@@ -30,7 +30,7 @@ import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { saveStygianRecommendationsAction } from "@/app/admin/stygian/actions";
-import { AutosaveStatus } from "@/components/admin/autosave-status";
+import { SaveStatus } from "@/components/admin/save-status";
import { StygianSyncButton } from "@/components/admin/stygian-sync-button";
import {
ArtifactSelect,
@@ -69,7 +69,7 @@ import {
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { elementIconFilter } from "@/lib/catalog/element";
-import { AutosaveCoordinator, type AutosaveStatus as CoordinatorStatus } from "@/lib/editor/autosave";
+import { ManualSaveCoordinator, type SaveStatus as CoordinatorStatus } from "@/lib/editor/manual-save";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { stygianBossPositionLabels, stygianBossPositions, stygianLevels } from "@/lib/stygian/constants";
import {
@@ -808,6 +808,8 @@ function BossRecommendationEditor({
weapons,
artifacts,
onVersionChange,
+ active,
+ onDirtyChange,
}: {
scheduleId: number;
version: number;
@@ -818,6 +820,8 @@ function BossRecommendationEditor({
weapons: CatalogItem[];
artifacts: CatalogItem[];
onVersionChange: (version: number) => void;
+ active: boolean;
+ onDirtyChange: (key: string, dirty: boolean) => void;
}) {
const groups = recommendations.filter(
(group) => group.level === level && group.bossPosition === boss.position,
@@ -851,15 +855,15 @@ function BossRecommendationEditor({
const latestDraftRef = useRef({ fingerprint, valid });
const [saveStatus, setSaveStatus] = useState({ state: "idle" });
const initialVersionRef = useRef(version);
- const coordinatorRef = useRef | null>(null);
- const saveState = !valid
- ? "incomplete"
- : saveStatus.state === "saving"
- ? "saving"
+ const saveState = saveStatus.state === "saving"
+ ? "saving"
+ : !valid
+ ? "incomplete"
: fingerprint === savedFingerprint
? "saved"
: saveStatus.state === "error"
@@ -867,10 +871,8 @@ function BossRecommendationEditor({
: "unsaved";
useEffect(() => {
- const coordinator = new AutosaveCoordinator({
+ const coordinator = new ManualSaveCoordinator({
initialVersion: initialVersionRef.current,
- debounceMs: 2_500,
- maxRetries: 0,
createPayload: (draft: string, expectedVersion) => ({
fingerprint: draft,
expectedVersion,
@@ -895,7 +897,7 @@ function BossRecommendationEditor({
savedFingerprintRef.current = draft;
setSavedFingerprint(draft);
const latest = latestDraftRef.current;
- if (latest.valid && latest.fingerprint !== draft) coordinator.schedule(latest.fingerprint);
+ if (latest.valid && latest.fingerprint !== draft) coordinator.stage(latest.fingerprint);
}
onVersionChange(savedVersion);
},
@@ -918,9 +920,13 @@ function BossRecommendationEditor({
if (!coordinator) return;
latestDraftRef.current = { fingerprint, valid };
if (!valid || fingerprint === savedFingerprintRef.current) coordinator.cancelPending();
- else coordinator.schedule(fingerprint);
+ else coordinator.stage(fingerprint);
}, [fingerprint, valid]);
+ useEffect(() => {
+ onDirtyChange(`${level}:${boss.position}`, fingerprint !== savedFingerprint || saveStatus.state === "saving");
+ }, [boss.position, fingerprint, level, onDirtyChange, savedFingerprint, saveStatus.state]);
+
return (
-
-
+ void coordinatorRef.current?.flush()}
+ canSave={valid && fingerprint !== savedFingerprint && saveStatus.state !== "saving"}
+ onSave={() => {
+ const coordinator = coordinatorRef.current;
+ if (!coordinator || !valid || saveStatus.state === "saving" || fingerprint === savedFingerprint) return;
+ coordinator.updateVersion(version);
+ coordinator.stage(fingerprint);
+ void coordinator.flush();
+ }}
/>
-
+ : null}
);
}
@@ -1058,6 +1071,37 @@ export function StygianEditor({
const [version, setVersion] = useState(schedule.version);
const [level, setLevel] = useState<105 | 110>(105);
const [bossPosition, setBossPosition] = useState(0);
+ const [visitedTabs, setVisitedTabs] = useState(() => new Set(["105:0"]));
+ const [dirtyTabs, setDirtyTabs] = useState