fix: support safe project deletion
This commit is contained in:
@@ -13,6 +13,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mutating queue requests", () => {
|
||||
it("deletes an admin project using its encoded id", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.deleteProject("project/one");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/admin/projects/project%2Fone");
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("sends an idempotency key when creating a ticket", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
ticket: { id: "ticket-1", display_number: "00001", status: "WAITING", party_size: 3 },
|
||||
|
||||
@@ -245,6 +245,9 @@ export const api = {
|
||||
updateProject(projectId: string, payload: import("./types").ProjectProfilePayload) {
|
||||
return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "PUT", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteProject(projectId: string) {
|
||||
return request<void>(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
},
|
||||
updateProjectSettings(projectId: string, payload: import("./types").UpdateProjectSettingsPayload) {
|
||||
return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}/settings`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { ApiError, api } from "../api";
|
||||
|
||||
const { refreshMock } = vi.hoisted(() => ({ refreshMock: vi.fn() }));
|
||||
|
||||
vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
@@ -71,12 +73,16 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
error: null,
|
||||
offline: false,
|
||||
lastClientSuccessAt: new Date().toISOString(),
|
||||
refresh: vi.fn(),
|
||||
refresh: refreshMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AdminPage } from "./AdminPage";
|
||||
|
||||
beforeEach(() => {
|
||||
refreshMock.mockReset();
|
||||
});
|
||||
|
||||
describe("AdminPage active tickets", () => {
|
||||
it("保留核心排队数据并删除辅助概览模块", () => {
|
||||
render(<MemoryRouter initialEntries={["/admin"]}><AdminPage /></MemoryRouter>);
|
||||
@@ -145,6 +151,46 @@ describe("AdminPage active tickets", () => {
|
||||
expect(screen.queryByRole("combobox", { name: "预计等待时间方式" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请在入口附近等候。");
|
||||
expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "删除项目" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("确认后删除项目,刷新数据并返回列表", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockImplementation(() => new Promise<void>((resolve) => { resolveDelete = resolve; }));
|
||||
refreshMock.mockResolvedValueOnce(undefined);
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
expect(deleteProject).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
|
||||
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith("project-1"));
|
||||
const deletingButton = screen.getByRole("button", { name: "正在删除" });
|
||||
expect(deletingButton).toBeDisabled();
|
||||
fireEvent.click(deletingButton);
|
||||
expect(deleteProject).toHaveBeenCalledTimes(1);
|
||||
resolveDelete();
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "项目管理" })).toBeVisible());
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("删除项目失败时留在维护页并显示服务端消息", async () => {
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockRejectedValue(new ApiError("项目存在活动排队,无法删除", 409));
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
|
||||
expect(await screen.findByText("项目存在活动排队,无法删除")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("创建项目与维护项目使用相同字段", () => {
|
||||
|
||||
@@ -337,10 +337,42 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
|
||||
}
|
||||
|
||||
function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) {
|
||||
const navigate = useNavigate();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects">返回项目列表</Link></div></section>;
|
||||
|
||||
const deleteProject = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.deleteProject(project.id);
|
||||
await onRefresh();
|
||||
navigate("/admin/projects", { replace: true });
|
||||
} catch (caught) {
|
||||
setDeleteError(caught instanceof ApiError ? caught.message : "项目删除失败,请稍后重试。");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="project-maintenance">
|
||||
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects">返回列表</Link></div>
|
||||
<ProjectForm project={project} onSaved={onRefresh} />
|
||||
<section className="panel" aria-label="危险操作">
|
||||
<div className="panel__header">
|
||||
<div><h3>危险操作</h3><p>删除后无法撤销,请确认不再需要该项目。</p></div>
|
||||
{!confirmingDelete
|
||||
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}>删除项目</button>
|
||||
: <div>
|
||||
<button className="button button--secondary" type="button" onClick={() => setConfirmingDelete(false)} disabled={deleting}>取消</button>{" "}
|
||||
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在删除" : "确认删除"}</button>
|
||||
</div>}
|
||||
</div>
|
||||
{deleteError ? <FeedbackBanner tone="danger" title="删除失败">{deleteError}</FeedbackBanner> : null}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1311,6 +1311,16 @@ h3 {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-soft);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
border-color: var(--color-border);
|
||||
background: transparent;
|
||||
|
||||
Reference in New Issue
Block a user