diff --git a/apps/web/e2e/tests/kanban/task-multi-select.spec.ts b/apps/web/e2e/tests/kanban/task-multi-select.spec.ts index 0c85f59679..443704cdf4 100644 --- a/apps/web/e2e/tests/kanban/task-multi-select.spec.ts +++ b/apps/web/e2e/tests/kanban/task-multi-select.spec.ts @@ -117,6 +117,74 @@ test.describe("Multi-select bulk actions", () => { await expect(kanban.multiSelectToolbar).not.toBeVisible(); }); + test("multi-select mode is disabled after bulk archive", async ({ + testPage, + apiClient, + seedData, + }) => { + const [t1, t2] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "MS Mode Reset 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "MS Mode Reset 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.selectTask(t1.id); + await kanban.selectTask(t2.id); + + // Multi-select mode should be active + await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible(); + + await kanban.bulkArchiveButton.click(); + await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 }); + await expect(kanban.taskCard(t2.id)).not.toBeVisible(); + + // Multi-select mode itself should be disabled, not just the toolbar + await expect(kanban.multiSelectToolbar).not.toBeVisible(); + await expect(testPage.locator('[data-multi-select-active="true"]')).not.toBeVisible(); + }); + + test("multi-select mode is disabled after bulk delete", async ({ + testPage, + apiClient, + seedData, + }) => { + const [t1, t2] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "MS Mode Reset Del 1", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "MS Mode Reset Del 2", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.selectTask(t1.id); + await kanban.selectTask(t2.id); + + await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible(); + + await kanban.bulkDeleteButton.click(); + await expect(kanban.bulkDeleteConfirm).toBeVisible(); + await kanban.bulkDeleteConfirm.click(); + + await expect(kanban.taskCard(t1.id)).not.toBeVisible({ timeout: 10000 }); + await expect(kanban.taskCard(t2.id)).not.toBeVisible(); + + // Multi-select mode itself should be disabled, not just the toolbar + await expect(kanban.multiSelectToolbar).not.toBeVisible(); + await expect(testPage.locator('[data-multi-select-active="true"]')).not.toBeVisible(); + }); + test("bulk move moves tasks to target step", async ({ testPage, apiClient, seedData }) => { // Pick a step that is not the start step as the move target const targetStep = seedData.steps.find((s) => s.id !== seedData.startStepId); diff --git a/apps/web/hooks/use-task-multi-select.test.ts b/apps/web/hooks/use-task-multi-select.test.ts new file mode 100644 index 0000000000..64ba717c18 --- /dev/null +++ b/apps/web/hooks/use-task-multi-select.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { multiSelectReducer, INITIAL_STATE } from "./use-task-multi-select"; + +describe("multiSelectReducer", () => { + it("reset returns initial state", () => { + const dirty = { + selectedIds: new Set(["a", "b"]), + isMultiSelectEnabled: true, + isDeleting: true, + isArchiving: false, + }; + expect(multiSelectReducer(dirty, { type: "reset" })).toBe(INITIAL_STATE); + }); + + it("toggle_select adds a task", () => { + const next = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "t1" }); + expect(next.selectedIds).toEqual(new Set(["t1"])); + }); + + it("toggle_select removes an already-selected task", () => { + const state = { ...INITIAL_STATE, selectedIds: new Set(["t1", "t2"]) }; + const next = multiSelectReducer(state, { type: "toggle_select", taskId: "t1" }); + expect(next.selectedIds).toEqual(new Set(["t2"])); + }); + + it("set_selected replaces the selection set", () => { + const state = { ...INITIAL_STATE, selectedIds: new Set(["old"]) }; + const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["a", "b"]) }); + expect(next.selectedIds).toEqual(new Set(["a", "b"])); + }); + + it("set_enabled controls isMultiSelectEnabled", () => { + const on = multiSelectReducer(INITIAL_STATE, { type: "set_enabled", value: true }); + expect(on.isMultiSelectEnabled).toBe(true); + const off = multiSelectReducer(on, { type: "set_enabled", value: false }); + expect(off.isMultiSelectEnabled).toBe(false); + }); + + describe("bulk operation state flags", () => { + it("set_deleting toggles isDeleting", () => { + const on = multiSelectReducer(INITIAL_STATE, { type: "set_deleting", value: true }); + expect(on.isDeleting).toBe(true); + const off = multiSelectReducer(on, { type: "set_deleting", value: false }); + expect(off.isDeleting).toBe(false); + }); + + it("set_archiving toggles isArchiving", () => { + const on = multiSelectReducer(INITIAL_STATE, { type: "set_archiving", value: true }); + expect(on.isArchiving).toBe(true); + const off = multiSelectReducer(on, { type: "set_archiving", value: false }); + expect(off.isArchiving).toBe(false); + }); + }); + + describe("bulk action scenarios (reducer-level)", () => { + it("all succeed: selectedIds empty + enabled false", () => { + const state = { + ...INITIAL_STATE, + selectedIds: new Set(["t1", "t2"]), + isMultiSelectEnabled: true, + }; + // Simulate: set_selected with empty failed set, then set_enabled false + const afterSelect = multiSelectReducer(state, { + type: "set_selected", + ids: new Set(), + }); + const afterDisable = multiSelectReducer(afterSelect, { type: "set_enabled", value: false }); + expect(afterDisable.selectedIds.size).toBe(0); + expect(afterDisable.isMultiSelectEnabled).toBe(false); + }); + + it("some fail: selectedIds contains failed IDs, enabled stays true", () => { + const state = { + ...INITIAL_STATE, + selectedIds: new Set(["t1", "t2", "t3"]), + isMultiSelectEnabled: true, + }; + // Simulate: set_selected with failed IDs only, no set_enabled call + const afterSelect = multiSelectReducer(state, { + type: "set_selected", + ids: new Set(["t2"]), + }); + expect(afterSelect.selectedIds).toEqual(new Set(["t2"])); + expect(afterSelect.isMultiSelectEnabled).toBe(true); + }); + + it("all fail: selectedIds unchanged, enabled stays true", () => { + const state = { + ...INITIAL_STATE, + selectedIds: new Set(["t1", "t2"]), + isMultiSelectEnabled: true, + }; + const afterSelect = multiSelectReducer(state, { + type: "set_selected", + ids: new Set(["t1", "t2"]), + }); + expect(afterSelect.selectedIds).toEqual(new Set(["t1", "t2"])); + expect(afterSelect.isMultiSelectEnabled).toBe(true); + }); + }); +}); diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index 8549b88bd6..f9ddc5db5e 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -86,6 +86,7 @@ function useBulkOperations({ setSelectedIds, setIsDeleting, setIsArchiving, + setIsMultiSelectEnabled, moveTaskById, deleteTaskById, archiveTaskById, @@ -98,6 +99,7 @@ function useBulkOperations({ setSelectedIds: (ids: Set) => void; setIsDeleting: (v: boolean) => void; setIsArchiving: (v: boolean) => void; + setIsMultiSelectEnabled: (v: boolean) => void; moveTaskById: ReturnType["moveTaskById"]; deleteTaskById: ReturnType["deleteTaskById"]; archiveTaskById: ReturnType["archiveTaskById"]; @@ -114,11 +116,20 @@ function useBulkOperations({ const results = await Promise.allSettled(idList.map((id) => deleteTaskById(id))); const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled")); removeTasksFromStore(succeeded); - setSelectedIds(new Set(idList.filter((_, i) => results[i].status === "rejected"))); + const failed = new Set(idList.filter((_, i) => results[i].status === "rejected")); + setSelectedIds(failed); + if (failed.size === 0) setIsMultiSelectEnabled(false); } finally { setIsDeleting(false); } - }, [deleteTaskById, removeTasksFromStore, selectedIdsRef, setIsDeleting, setSelectedIds]); + }, [ + deleteTaskById, + removeTasksFromStore, + selectedIdsRef, + setIsDeleting, + setIsMultiSelectEnabled, + setSelectedIds, + ]); const bulkArchive = useCallback(async () => { const ids = selectedIdsRef.current; @@ -129,11 +140,20 @@ function useBulkOperations({ const results = await Promise.allSettled(idList.map((id) => archiveTaskById(id))); const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled")); removeTasksFromStore(succeeded); - setSelectedIds(new Set(idList.filter((_, i) => results[i].status === "rejected"))); + const failed = new Set(idList.filter((_, i) => results[i].status === "rejected")); + setSelectedIds(failed); + if (failed.size === 0) setIsMultiSelectEnabled(false); } finally { setIsArchiving(false); } - }, [archiveTaskById, removeTasksFromStore, selectedIdsRef, setIsArchiving, setSelectedIds]); + }, [ + archiveTaskById, + removeTasksFromStore, + selectedIdsRef, + setIsArchiving, + setIsMultiSelectEnabled, + setSelectedIds, + ]); const bulkMove = useCallback( async (targetStepId: string) => { @@ -174,14 +194,19 @@ type MultiSelectAction = | { type: "set_deleting"; value: boolean } | { type: "set_archiving"; value: boolean }; -const INITIAL_STATE: MultiSelectState = { +/** @internal Exported for testing. */ +export const INITIAL_STATE: MultiSelectState = { selectedIds: new Set(), isMultiSelectEnabled: false, isDeleting: false, isArchiving: false, }; -function multiSelectReducer(state: MultiSelectState, action: MultiSelectAction): MultiSelectState { +/** @internal Exported for testing. */ +export function multiSelectReducer( + state: MultiSelectState, + action: MultiSelectAction, +): MultiSelectState { switch (action.type) { case "reset": return INITIAL_STATE; @@ -266,6 +291,7 @@ export function useTaskMultiSelect(workflowId: string | null) { setSelectedIds, setIsDeleting, setIsArchiving, + setIsMultiSelectEnabled, moveTaskById, deleteTaskById, archiveTaskById,