feat: add bulk delete for session manager (#1693)

* feat: add bulk delete for session manager

* fix: address batch delete review issues

* fix: keep session list in sync after batch delete
This commit is contained in:
Alexlangl
2026-03-30 22:15:57 +08:00
committed by GitHub
parent 4f7ea76347
commit 8e2ffbc845
11 changed files with 960 additions and 218 deletions
+151 -8
View File
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
screen,
@@ -8,6 +9,7 @@ import {
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
@@ -62,16 +64,19 @@ const renderPage = () => {
},
});
return render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
);
return {
client,
...render(
<QueryClientProvider client={client}>
<SessionManagerPage appId="codex" />
</QueryClientProvider>,
),
};
};
const openSearch = () => {
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
button.querySelector(".lucide-search"),
const searchButton = Array.from(screen.getAllByRole("button")).find(
(button) => button.querySelector(".lucide-search"),
);
if (!searchButton) {
@@ -81,10 +86,23 @@ const openSearch = () => {
fireEvent.click(searchButton);
};
const closeSearch = () => {
const closeButton = Array.from(screen.getAllByRole("button")).find(
(button) => button.querySelector(".lucide-x"),
);
if (!closeButton) {
throw new Error("Search close button not found");
}
fireEvent.click(closeButton);
};
describe("SessionManagerPage", () => {
beforeEach(() => {
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
Element.prototype.scrollIntoView = vi.fn();
const sessions: SessionMeta[] = [
{
@@ -178,11 +196,136 @@ describe("SessionManagerPage", () => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
);
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
expect(
screen.getByText("sessionManager.selectSession"),
).toBeInTheDocument();
expect(
screen.queryByText("sessionManager.emptySession"),
).not.toBeInTheDocument();
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
});
it("restores batch delete controls when deleteMany rejects", async () => {
const deleteManySpy = vi
.spyOn(sessionsApi, "deleteMany")
.mockRejectedValueOnce(new Error("network error"));
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
const dialog = screen.getByTestId("confirm-dialog");
fireEvent.click(
within(dialog).getByRole("button", { name: /删除所选会话/i }),
);
await waitFor(() =>
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /批量删除/i }),
).not.toBeDisabled(),
);
deleteManySpy.mockRestore();
});
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
openSearch();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "NoSuchSession" },
});
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
});
it("drops hidden selections when search narrows the result set", async () => {
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
openSearch();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "Alpha" },
});
await waitFor(() =>
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
);
closeSearch();
await waitFor(() =>
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
);
});
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
const view = renderPage();
let resolveInvalidate!: () => void;
const invalidateSpy = vi
.spyOn(view.client, "invalidateQueries")
.mockImplementation(
() =>
new Promise((resolve) => {
resolveInvalidate = () => resolve(undefined);
}),
);
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Alpha Session" }),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
const dialog = screen.getByTestId("confirm-dialog");
fireEvent.click(
within(dialog).getByRole("button", { name: /删除所选会话/i }),
);
await waitFor(() => {
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
});
await act(async () => {
resolveInvalidate();
});
invalidateSpy.mockRestore();
});
});