fix(web): disable multi-select mode after bulk archive or delete (#623)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user