Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { makeThread } from "@bb/test-helpers/domain-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadArchiveDialog } from "./ThreadArchiveDialog";

afterEach(() => {
cleanup();
});

function renderDialog({
childThreadCount,
status = "idle",
}: {
childThreadCount?: number;
status?: "idle" | "starting" | "active" | "stopping";
} = {}) {
const onArchive = vi.fn();
const onOpenChange = vi.fn();
const thread = makeThread({ status });
const view = render(
<ThreadArchiveDialog
target={{ thread, childThreadCount }}
pending={false}
onOpenChange={onOpenChange}
onArchive={onArchive}
/>,
);
return { onArchive, onOpenChange, thread, view };
}

describe("ThreadArchiveDialog", () => {
it("announces the cascade with singular and plural child counts", () => {
const { view } = renderDialog({ childThreadCount: 1 });
expect(
screen.getByText(/1 child thread will be archived too\./),
).toBeTruthy();

view.unmount();
renderDialog({ childThreadCount: 3 });
expect(
screen.getByText(/3 child threads will be archived too\./),
).toBeTruthy();
});

it("omits the cascade sentence when the thread has no children", () => {
renderDialog();

expect(screen.queryByText(/will be archived too/)).toBeNull();
expect(
screen.getByText(
"Archived threads stay available and can be unarchived.",
),
).toBeTruthy();
});

it.each(["starting", "active", "stopping"] as const)(
"warns that current work will stop for a %s thread",
(status) => {
renderDialog({ status });
expect(screen.getByText(/This will stop current work\./)).toBeTruthy();
},
);

it("omits the active-work warning for an idle thread", () => {
renderDialog();
expect(screen.queryByText(/This will stop current work\./)).toBeNull();
});

it("archives only when confirmation is accepted", () => {
const { onArchive, onOpenChange, thread } = renderDialog({
childThreadCount: 2,
});

fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onArchive).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);

fireEvent.click(screen.getByRole("button", { name: "Archive thread" }));
expect(onArchive).toHaveBeenCalledWith({ thread, childThreadCount: 2 });
});
});
97 changes: 97 additions & 0 deletions apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Thread } from "@bb/domain";
import { Button } from "@bb/shared-ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@bb/shared-ui/dialog";

export interface ThreadArchiveDialogTarget {
thread: Thread;
childThreadCount?: number;
}

interface ThreadArchiveDialogProps {
target: ThreadArchiveDialogTarget | null;
pending: boolean;
onOpenChange: (open: boolean) => void;
onArchive: (target: ThreadArchiveDialogTarget) => void;
}

export function ThreadArchiveDialog({
target,
pending,
onOpenChange,
onArchive,
}: ThreadArchiveDialogProps) {
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent>
{target ? (
<ThreadArchiveDialogContent
target={target}
pending={pending}
onOpenChange={onOpenChange}
onArchive={onArchive}
/>
) : null}
</DialogContent>
</Dialog>
);
}

interface ThreadArchiveDialogContentProps {
target: ThreadArchiveDialogTarget;
pending: boolean;
onOpenChange: (open: boolean) => void;
onArchive: (target: ThreadArchiveDialogTarget) => void;
}

export function ThreadArchiveDialogContent({
target,
pending,
onOpenChange,
onArchive,
}: ThreadArchiveDialogContentProps) {
const childThreadCount = target.childThreadCount ?? 0;
const active =
target.thread.status === "starting" ||
target.thread.status === "active" ||
target.thread.status === "stopping";
const sentences = [
active ? "This will stop current work." : null,
childThreadCount > 0
? `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be archived too.`
: null,
"Archived threads stay available and can be unarchived.",
].filter((sentence): sentence is string => sentence !== null);

return (
<>
<DialogHeader>
<DialogTitle>Archive thread?</DialogTitle>
<DialogDescription>{sentences.join(" ")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={pending}
onClick={() => onArchive(target)}
>
Archive thread
</Button>
</DialogFooter>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ vi.mock("@/components/thread/ThreadActionsProvider", () => ({
renameThread: vi.fn(),
requestRename: vi.fn(),
requestDelete: vi.fn(),
archiveThreadAndChildren: vi.fn(),
requestArchive: vi.fn(),
unarchiveThread: vi.fn(),
togglePin: vi.fn(),
toggleRead: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock("@/components/thread/ThreadActionsProvider", () => ({
renameThread: vi.fn(),
requestRename: vi.fn(),
requestDelete: vi.fn(),
archiveThreadAndChildren: vi.fn(),
requestArchive: vi.fn(),
unarchiveThread: vi.fn(),
togglePin: vi.fn(),
toggleRead: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/thread/ThreadActionsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ThreadSectionMoveProvider } from "./ThreadSectionMoveProvider";
const moveThreadToSection = vi.hoisted(() => vi.fn());
const copyToClipboardWithToast = vi.hoisted(() => vi.fn());
const threadActions = vi.hoisted(() => ({
archiveThreadAndChildren: vi.fn(),
requestArchive: vi.fn(),
requestDelete: vi.fn(),
requestRename: vi.fn(),
togglePin: vi.fn(),
Expand Down
10 changes: 6 additions & 4 deletions apps/app/src/components/thread/ThreadActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ function ThreadActionsMenuItems({
surface,
}: ThreadActionsMenuItemsProps) {
const {
archiveThreadAndChildren,
requestArchive,
requestRename,
requestDelete,
togglePin,
Expand Down Expand Up @@ -299,7 +299,9 @@ function ThreadActionsMenuItems({
unarchiveThread(thread);
return;
}
archiveThreadAndChildren(thread);
window.setTimeout(() => {
requestArchive(thread);
}, 0);
}}
>
{isArchived ? "Unarchive" : "Archive"}
Expand Down Expand Up @@ -343,7 +345,7 @@ export function ThreadArchiveQuickAction({
thread: Thread;
className?: string;
}) {
const { archiveThreadAndChildren, unarchiveThread } = useThreadActions();
const { requestArchive, unarchiveThread } = useThreadActions();
const isArchived = thread.archivedAt != null;
const label = isArchived ? "Unarchive" : "Archive";
return (
Expand All @@ -362,7 +364,7 @@ export function ThreadArchiveQuickAction({
unarchiveThread(thread);
return;
}
archiveThreadAndChildren(thread);
requestArchive(thread);
}}
>
<Icon
Expand Down
50 changes: 36 additions & 14 deletions apps/app/src/components/thread/ThreadActionsProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ import {

const mocks = vi.hoisted(() => ({
closePanesForThreads: vi.fn(),
dialogOnClose: vi.fn(),
dialogOnOpen: vi.fn(),
dialogOnOpenChange: vi.fn(),
mutation: vi.fn(),
navigate: vi.fn(),
}));
Expand Down Expand Up @@ -80,15 +77,6 @@ vi.mock("@/lib/sdk", () => ({
},
}));

vi.mock("@/hooks/useDialogState", () => ({
useDialogState: () => ({
onClose: mocks.dialogOnClose,
onOpen: mocks.dialogOnOpen,
onOpenChange: mocks.dialogOnOpenChange,
target: null,
}),
}));

vi.mock("@/hooks/useRouteState", () => ({
useRouteState: () => ({ threadId: null }),
}));
Expand All @@ -107,9 +95,9 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
}

function ArchiveButton({ thread }: { thread: Thread }) {
const { archiveThreadAndChildren } = useThreadActions();
const { requestArchive } = useThreadActions();
return (
<button type="button" onClick={() => archiveThreadAndChildren(thread)}>
<button type="button" onClick={() => requestArchive(thread)}>
Archive
</button>
);
Expand All @@ -136,6 +124,9 @@ beforeEach(() => {
archivedThreadIds: ["thr_parent", "thr_child"],
ok: true,
});
vi.mocked(sdk.threads.childSummary).mockResolvedValue({
nonDeletedChildCount: 1,
});
vi.mocked(sdk.threads.unarchive).mockResolvedValue({ ok: true });
mocks.closePanesForThreads.mockReturnValue({
focusedRoute: null,
Expand All @@ -148,11 +139,42 @@ afterEach(() => {
vi.clearAllMocks();
});

describe("ThreadActionsProvider archive confirmation", () => {
it("reports child threads and archives nothing before confirmation", async () => {
vi.mocked(sdk.threads.childSummary).mockResolvedValue({
nonDeletedChildCount: 4,
});
renderProvider(<ArchiveButton thread={makeThread()} />);

fireEvent.click(screen.getByRole("button", { name: "Archive" }));

expect(
await screen.findByText(/4 child threads will be archived too\./),
).not.toBeNull();
expect(sdk.threads.archiveAll).not.toHaveBeenCalled();
});

it("leaves a thread unchanged when confirmation is cancelled", async () => {
renderProvider(<ArchiveButton thread={makeThread()} />);

fireEvent.click(screen.getByRole("button", { name: "Archive" }));
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));

expect(
screen.queryByRole("heading", { name: "Archive thread?" }),
).toBeNull();
expect(sdk.threads.archiveAll).not.toHaveBeenCalled();
});
});

describe("ThreadActionsProvider archive feedback", () => {
it("shows one archive toast whose Undo restores the parent and children", async () => {
renderProvider(<ArchiveButton thread={makeThread()} />);

fireEvent.click(screen.getByRole("button", { name: "Archive" }));
fireEvent.click(
await screen.findByRole("button", { name: "Archive thread" }),
);

await vi.waitFor(() => {
expect(appToast.success).toHaveBeenCalledTimes(1);
Expand Down
Loading
Loading