fix(web): disable multi-select mode after bulk archive or delete (#623)

This commit is contained in:
Carlos Florêncio
2026-04-17 19:36:19 +01:00
committed by GitHub
parent f440007efc
commit cb8abfed78
3 changed files with 201 additions and 6 deletions
@@ -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);
@@ -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<string>(),
});
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);
});
});
});
+32 -6
View File
@@ -86,6 +86,7 @@ function useBulkOperations({
setSelectedIds,
setIsDeleting,
setIsArchiving,
setIsMultiSelectEnabled,
moveTaskById,
deleteTaskById,
archiveTaskById,
@@ -98,6 +99,7 @@ function useBulkOperations({
setSelectedIds: (ids: Set<string>) => void;
setIsDeleting: (v: boolean) => void;
setIsArchiving: (v: boolean) => void;
setIsMultiSelectEnabled: (v: boolean) => void;
moveTaskById: ReturnType<typeof useTaskActions>["moveTaskById"];
deleteTaskById: ReturnType<typeof useTaskActions>["deleteTaskById"];
archiveTaskById: ReturnType<typeof useTaskActions>["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,