Files
kandev/apps/web/hooks/use-sidebar-multi-select.test.ts
T
José Almeida e2c076c71c feat(web): improve mobile task navigation (#1769)
* feat(web): improve mobile task and kanban navigation

* feat(web): refine mobile task navigation

* fix(web): address mobile navigation review feedback

* test(web): cover task title field modes

* fix(web): keep mobile board navigator open across workflows

* fix(web): address mobile workflow review feedback
2026-07-18 02:28:29 +01:00

218 lines
8.8 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const archiveTaskById = vi.fn();
const deleteTaskById = vi.fn();
const archiveAndSwitch = vi.fn();
const removeTaskFromBoard = vi.fn();
const removeTasksFromStore = vi.fn();
const getWorkflowIdForTask = vi.fn();
const moveTasks = vi.fn();
const toast = vi.fn();
let activeTaskId: string | null = null;
vi.mock("./use-task-actions", () => ({
useTaskActions: () => ({ archiveTaskById, deleteTaskById }),
useArchiveAndSwitchTask: () => archiveAndSwitch,
}));
vi.mock("./use-task-removal", () => ({ useTaskRemoval: () => ({ removeTaskFromBoard }) }));
vi.mock("./use-task-workflow-move", () => ({ useTaskWorkflowMove: () => moveTasks }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast }) }));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({
getState: () => ({ tasks: { activeTaskId, activeSessionId: null } }),
}),
}));
vi.mock("./use-task-multi-select", async (importOriginal) => {
const actual = await importOriginal<typeof import("./use-task-multi-select")>();
return {
...actual,
useTaskMultiSelectStore: () => ({ getWorkflowIdForTask, removeTasksFromStore }),
};
});
import { useSidebarMultiSelect } from "./use-sidebar-multi-select";
beforeEach(() => {
activeTaskId = null;
archiveTaskById.mockReset().mockResolvedValue(undefined);
deleteTaskById.mockReset().mockResolvedValue(undefined);
archiveAndSwitch.mockReset().mockResolvedValue(undefined);
removeTaskFromBoard.mockReset().mockResolvedValue(undefined);
removeTasksFromStore.mockReset();
getWorkflowIdForTask.mockReset().mockReturnValue("wf1");
moveTasks.mockReset().mockResolvedValue(undefined);
toast.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("useSidebarMultiSelect", () => {
it("toggles, ranges, and clears the selection", () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
expect(result.current.isSelecting).toBe(false);
act(() => result.current.toggleSelect("a"));
act(() => result.current.toggleSelect("b"));
expect(result.current.selectedIds).toEqual(new Set(["a", "b"]));
expect(result.current.isSelecting).toBe(true);
act(() => result.current.clearSelection());
expect(result.current.selectedIds.size).toBe(0);
});
it("pruneToVisible drops selected ids that are no longer visible", () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
act(() => result.current.toggleSelect("a"));
act(() => result.current.toggleSelect("b"));
act(() => result.current.pruneToVisible(["a"]));
expect(result.current.selectedIds).toEqual(new Set(["a"]));
// No-op when everything is still visible.
act(() => result.current.pruneToVisible(["a", "b"]));
expect(result.current.selectedIds).toEqual(new Set(["a"]));
});
it("pruneToVisible realigns the anchor so a later range starts from a visible id", () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
act(() => result.current.toggleSelect("a"));
act(() => result.current.toggleSelect("b")); // anchor is now "b"
act(() => result.current.pruneToVisible(["a"])); // drops "b", anchor must realign to "a"
// Range from the realigned anchor "a" to "c" includes the in-between "x".
// A stale "b" anchor (absent from orderedIds) would fall back to just {a, c}.
act(() => result.current.selectRange("c", ["a", "x", "c"]));
expect(result.current.selectedIds).toEqual(new Set(["a", "x", "c"]));
});
it("resets the selection when the workspace changes", () => {
const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), {
initialProps: { ws: "ws1" },
});
act(() => result.current.toggleSelect("a"));
expect(result.current.selectedIds.size).toBe(1);
rerender({ ws: "ws2" });
expect(result.current.selectedIds.size).toBe(0);
});
});
describe("useSidebarMultiSelect — bulk actions", () => {
it("bulkArchive removes all on full success and clears the selection", async () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkArchive(["a", "b"]);
});
expect(archiveTaskById).toHaveBeenCalledTimes(2);
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"]));
expect(result.current.selectedIds.size).toBe(0);
expect(toast).not.toHaveBeenCalled();
});
it("bulkArchive keeps the failed ids selected and toasts on partial failure", async () => {
archiveTaskById.mockImplementation((id) =>
id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(),
);
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkArchive(["a", "b"]);
});
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a"]));
expect(result.current.selectedIds).toEqual(new Set(["b"]));
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it("bulkArchive routes the active task through the switch-aware path", async () => {
activeTaskId = "a";
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkArchive(["a", "b"]);
});
expect(archiveAndSwitch).toHaveBeenCalledWith("a", undefined);
expect(archiveTaskById).toHaveBeenCalledTimes(1);
expect(archiveTaskById).toHaveBeenCalledWith("b", undefined);
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["b"]));
});
it("bulkArchive ignores an empty id list", async () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkArchive([]);
});
expect(archiveTaskById).not.toHaveBeenCalled();
});
it("bulkDelete removes all on full success and clears the selection", async () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkDelete(["a", "b"]);
});
expect(deleteTaskById).toHaveBeenCalledTimes(2);
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"]));
expect(result.current.selectedIds.size).toBe(0);
expect(toast).not.toHaveBeenCalled();
});
it("bulkDelete keeps failed ids selected and toasts on partial failure", async () => {
deleteTaskById.mockImplementation((id: string) =>
id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(),
);
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkDelete(["a", "b"]);
});
expect(result.current.selectedIds).toEqual(new Set(["b"]));
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it("bulkDelete routes the active task through the switch-aware removal", async () => {
activeTaskId = "a";
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
await act(async () => {
await result.current.bulkDelete(["a", "b"]);
});
// 'b' deleted directly; 'a' (active) deleted then removed-from-board to switch.
expect(deleteTaskById).toHaveBeenCalledWith("b", undefined);
expect(deleteTaskById).toHaveBeenCalledWith("a", undefined);
expect(removeTaskFromBoard).toHaveBeenCalledWith(
"a",
expect.objectContaining({ wasActiveTaskId: "a" }),
);
});
});
describe("useSidebarMultiSelect — bulk move", () => {
it("bulkMove classifies a same-workflow target as a step move", async () => {
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
act(() => result.current.toggleSelect("a"));
await act(async () => {
await result.current.bulkMove(["a"], "wf1", "s1");
});
expect(moveTasks).toHaveBeenCalledWith(["a"], "wf1", "s1", "step");
expect(result.current.selectedIds.size).toBe(0);
});
it("bulkMove classifies a cross-workflow target as a workflow move", async () => {
getWorkflowIdForTask.mockReturnValue("wf2");
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
act(() => result.current.toggleSelect("a"));
await act(async () => {
await result.current.bulkMove(["a"], "wf1", "s1");
});
expect(moveTasks).toHaveBeenCalledWith(["a"], "wf1", "s1", "workflow");
expect(result.current.selectedIds.size).toBe(0);
});
it("bulkMove keeps the selection and swallows the rejection on failure", async () => {
moveTasks.mockRejectedValue(new Error("locked"));
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
act(() => result.current.toggleSelect("a"));
await act(async () => {
await result.current.bulkMove(["a"], "wf1", "s1");
});
expect(result.current.selectedIds).toEqual(new Set(["a"]));
});
});