fix(ui): show archive progress before removal (#3730)
This commit is contained in:
@@ -56,6 +56,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const {
|
||||
allTasks,
|
||||
pendingArchiveTaskIds,
|
||||
allSteps,
|
||||
stepsByWorkflowId,
|
||||
wipQueueByTaskId,
|
||||
@@ -100,6 +101,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
repositoriesById,
|
||||
stepColorById: new Map(allSteps.map((step) => [step.id, step.color])),
|
||||
automaticColorSettings,
|
||||
pendingArchiveTaskIds,
|
||||
};
|
||||
const items: TaskSwitcherItem[] = workspaceContextAccessDenied
|
||||
? []
|
||||
@@ -122,6 +124,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
repositoriesById: ctx.repositoriesById,
|
||||
stepColorById: ctx.stepColorById,
|
||||
automaticColorSettings: ctx.automaticColorSettings,
|
||||
pendingArchiveTaskIds: ctx.pendingArchiveTaskIds,
|
||||
};
|
||||
items.unshift(buildArchivedSidebarItem(archivedState, archivedContext));
|
||||
}
|
||||
@@ -136,6 +139,7 @@ export function useSheetData(workspaceId: string | null) {
|
||||
dismissedAgentErrors,
|
||||
wipQueueByTaskId,
|
||||
automaticColorSettings,
|
||||
pendingArchiveTaskIds,
|
||||
archivedState,
|
||||
workspaceContextAccessDenied,
|
||||
]);
|
||||
|
||||
@@ -54,6 +54,24 @@ describe("toSheetItem repository projection", () => {
|
||||
|
||||
expect(item.priority).toBe("critical");
|
||||
});
|
||||
|
||||
it("marks active rows covered by a pending archive", () => {
|
||||
const item = toSheetItem(task(), {
|
||||
...emptyCtx(),
|
||||
pendingArchiveTaskIds: new Set(["t1"]),
|
||||
});
|
||||
|
||||
expect(item.isPendingArchive).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark confirmed archived rows as pending", () => {
|
||||
const item = toSheetItem(task({ isArchived: true }), {
|
||||
...emptyCtx(),
|
||||
pendingArchiveTaskIds: new Set(["t1"]),
|
||||
});
|
||||
|
||||
expect(item.isPendingArchive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSheetItem remote executor projection", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SheetItemCtx = {
|
||||
repositoriesById?: ReadonlyMap<string, Repository>;
|
||||
stepColorById?: ReadonlyMap<string, string>;
|
||||
automaticColorSettings?: SidebarTaskColorAutomation;
|
||||
pendingArchiveTaskIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
|
||||
@@ -114,6 +115,7 @@ export function toSheetItem(
|
||||
primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined,
|
||||
workflowStepColor: facts.workflowStepColor,
|
||||
isArchived: task.isArchived === true,
|
||||
isPendingArchive: !task.isArchived && ctx.pendingArchiveTaskIds?.has(task.id) === true,
|
||||
isFromOffice: task.isFromOffice,
|
||||
isRemoteExecutor: task.isRemoteExecutor,
|
||||
remoteExecutorId: task.primaryExecutorId ?? undefined,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { TaskItem } from "./task-item";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("TaskItem pending archive state", () => {
|
||||
it("dims the row and shows a spinner while archive is pending", () => {
|
||||
render(
|
||||
<StateProvider>
|
||||
<TooltipProvider>
|
||||
<TaskItem title="Needs answer" state="REVIEW" isPendingArchive />
|
||||
</TooltipProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId("sidebar-task-item");
|
||||
expect(row.getAttribute("aria-busy")).toBe("true");
|
||||
expect(row.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(row.className).toContain("opacity-60");
|
||||
expect(screen.getByTestId("task-state-archive-pending").className).toContain("animate-spin");
|
||||
expect(screen.queryByTestId("task-state-turn-finished")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ type TaskItemProps = {
|
||||
*/
|
||||
parkedOnBackgroundWork?: boolean;
|
||||
isArchived?: boolean;
|
||||
isPendingArchive?: boolean;
|
||||
isSelected?: boolean;
|
||||
/** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */
|
||||
isMultiSelected?: boolean;
|
||||
@@ -179,6 +180,15 @@ function taskItemRowClick(
|
||||
return (e) => (onSelect ? onSelect(e) : onClick?.());
|
||||
}
|
||||
|
||||
function pendingArchiveRowProps(isPendingArchive?: boolean) {
|
||||
if (!isPendingArchive) return {};
|
||||
return {
|
||||
"aria-busy": true as const,
|
||||
"aria-disabled": true as const,
|
||||
className: "cursor-wait opacity-60",
|
||||
};
|
||||
}
|
||||
|
||||
function TaskItemTitle({ title }: { title: string }) {
|
||||
return <ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>;
|
||||
}
|
||||
@@ -334,6 +344,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
foregroundActivity,
|
||||
parkedOnBackgroundWork,
|
||||
isArchived,
|
||||
isPendingArchive,
|
||||
isSelected = false,
|
||||
isMultiSelected = false,
|
||||
onClick,
|
||||
@@ -374,6 +385,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
taskRowPresentation,
|
||||
}: TaskItemProps) {
|
||||
const effectiveMenuOpen = menuOpen || isDeleting === true;
|
||||
const pendingProps = pendingArchiveRowProps(isPendingArchive);
|
||||
const resolvedTaskRow = resolveTaskRowPresentation(taskRowPresentation, { showRepository });
|
||||
const relativeTime = showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt;
|
||||
const taskColor = useTaskColor(taskId);
|
||||
@@ -386,6 +398,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
tabIndex={0}
|
||||
data-testid="sidebar-task-item"
|
||||
data-task-row-id={taskId}
|
||||
{...pendingProps}
|
||||
{...taskItemStateAttrs(isSelected, isMultiSelected)}
|
||||
onClick={taskItemRowClick(onSelect, onClick)}
|
||||
onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)}
|
||||
@@ -397,6 +410,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
indent.depth === 0,
|
||||
resolvedTaskRow.detailsEnabled,
|
||||
),
|
||||
pendingProps.className,
|
||||
archiveConfirmation && "flex-wrap",
|
||||
)}
|
||||
>
|
||||
@@ -412,6 +426,7 @@ export const TaskItem = memo(function TaskItem({
|
||||
parkedOnBackgroundWork={parkedOnBackgroundWork}
|
||||
hasPendingClarification={hasPendingClarification}
|
||||
hasPendingPermission={hasPendingPermission}
|
||||
isPendingArchive={isPendingArchive}
|
||||
interrupted={interrupted}
|
||||
isOnLastWorkflowStep={isOnLastWorkflowStep}
|
||||
showBackgroundTooltip
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSidebarItem } from "./task-session-sidebar-item";
|
||||
|
||||
type SidebarTask = Parameters<typeof buildSidebarItem>[0];
|
||||
type SidebarContext = Parameters<typeof buildSidebarItem>[1];
|
||||
|
||||
function emptyContext(): SidebarContext {
|
||||
return {
|
||||
repositorySlugById: new Map(),
|
||||
titleById: new Map(),
|
||||
workflowNameById: new Map(),
|
||||
stepTitleById: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SidebarTask> = {}): SidebarTask {
|
||||
return {
|
||||
id: "t1",
|
||||
_workflowId: "wf1",
|
||||
title: "Task",
|
||||
workflowStepId: "step-1",
|
||||
...overrides,
|
||||
} as SidebarTask;
|
||||
}
|
||||
|
||||
describe("buildSidebarItem pending archive projection", () => {
|
||||
it("marks active rows covered by a pending archive", () => {
|
||||
const item = buildSidebarItem(task(), {
|
||||
...emptyContext(),
|
||||
pendingArchiveTaskIds: new Set(["t1"]),
|
||||
});
|
||||
|
||||
expect(item.isPendingArchive).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark confirmed archived rows as pending", () => {
|
||||
const item = buildSidebarItem(task({ isArchived: true }), {
|
||||
...emptyContext(),
|
||||
pendingArchiveTaskIds: new Set(["t1"]),
|
||||
});
|
||||
|
||||
expect(item.isPendingArchive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export type SidebarItemContext = {
|
||||
repositoriesById?: ReadonlyMap<string, Repository>;
|
||||
stepColorById?: ReadonlyMap<string, string>;
|
||||
automaticColorSettings?: SidebarTaskColorAutomation;
|
||||
pendingArchiveTaskIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
|
||||
@@ -120,6 +121,14 @@ function sidebarStatus(
|
||||
};
|
||||
}
|
||||
|
||||
function isPendingArchive(
|
||||
task: KanbanState["tasks"][number],
|
||||
context: SidebarItemContext,
|
||||
): boolean {
|
||||
if (task.isArchived === true) return false;
|
||||
return context.pendingArchiveTaskIds?.has(task.id) === true;
|
||||
}
|
||||
|
||||
/** Map a task-level status projection to a sidebar item without session streams. */
|
||||
export function buildSidebarItem(
|
||||
task: KanbanState["tasks"][number] & { _workflowId: string },
|
||||
@@ -159,6 +168,7 @@ export function buildSidebarItem(
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
createdAt: task.createdAt,
|
||||
isArchived: task.isArchived === true,
|
||||
isPendingArchive: isPendingArchive(task, context),
|
||||
isFromOffice: task.isFromOffice,
|
||||
parentTaskTitle: task.parentTaskId ? context.titleById.get(task.parentTaskId) : undefined,
|
||||
parentTaskId: task.parentTaskId ?? undefined,
|
||||
|
||||
@@ -76,6 +76,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
|
||||
const {
|
||||
allTasks,
|
||||
pendingArchiveTaskIds,
|
||||
allSteps,
|
||||
stepsByWorkflowId,
|
||||
wipQueueByTaskId,
|
||||
@@ -115,6 +116,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
repositoriesById,
|
||||
stepColorById,
|
||||
automaticColorSettings,
|
||||
pendingArchiveTaskIds,
|
||||
};
|
||||
const items: TaskSwitcherItem[] = allTasks.map((task) => buildSidebarItem(task, mapCtx));
|
||||
if (
|
||||
@@ -136,6 +138,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
acknowledgedAgentErrors,
|
||||
dismissedAgentErrors,
|
||||
automaticColorSettings,
|
||||
pendingArchiveTaskIds,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -34,6 +34,8 @@ export type TaskStateIconProps = {
|
||||
* foregroundActivity (AC-34).
|
||||
*/
|
||||
parkedOnBackgroundWork?: boolean;
|
||||
/** True while an accepted archive request is still in flight. */
|
||||
isPendingArchive?: boolean;
|
||||
accessibleLabel?: string;
|
||||
showBackgroundTooltip?: boolean;
|
||||
};
|
||||
@@ -133,7 +135,24 @@ function TaskReviewIcon({
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskStateIcon({
|
||||
function PendingArchiveTaskIcon() {
|
||||
return (
|
||||
<CompositorSpin
|
||||
aria-hidden="true"
|
||||
data-testid="task-state-archive-pending"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
||||
>
|
||||
<IconCircleDashed className="size-full" />
|
||||
</CompositorSpin>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskStateIcon(props: TaskStateIconProps) {
|
||||
if (props.isPendingArchive) return <PendingArchiveTaskIcon />;
|
||||
return <TaskStateIconContent {...props} />;
|
||||
}
|
||||
|
||||
function TaskStateIconContent({
|
||||
sessionState,
|
||||
state,
|
||||
foregroundActivity,
|
||||
|
||||
@@ -172,6 +172,7 @@ function TaskRowItem({
|
||||
interrupted={task.interrupted}
|
||||
parkedOnBackgroundWork={task.parkedOnBackgroundWork}
|
||||
isArchived={task.isArchived}
|
||||
isPendingArchive={task.isPendingArchive}
|
||||
isSelected={isSelected}
|
||||
diffStats={task.diffStats}
|
||||
comparisonUnavailable={task.comparisonUnavailable}
|
||||
|
||||
@@ -62,6 +62,8 @@ export type TaskSwitcherItem = {
|
||||
lastActivityAt?: string;
|
||||
createdAt?: string;
|
||||
isArchived?: boolean;
|
||||
/** True while an accepted archive request is still in flight. */
|
||||
isPendingArchive?: boolean;
|
||||
isFromOffice?: boolean;
|
||||
primarySessionId?: string | null;
|
||||
hasPendingClarification?: boolean;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { checkImmediateArchive } from "./sidebar-immediate-archive-helpers";
|
||||
|
||||
test("phone picker shows archive progress and restores failed archive", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
await checkImmediateArchive({
|
||||
page: testPage,
|
||||
api: apiClient,
|
||||
seed: seedData,
|
||||
mobile: true,
|
||||
screenshotPath: testInfo.outputPath("pending-archive.png"),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, type Page, type Route } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
// @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3
|
||||
export async function checkImmediateArchive(options: {
|
||||
page: Page;
|
||||
api: ApiClient;
|
||||
seed: SeedData;
|
||||
mobile: boolean;
|
||||
screenshotPath: string;
|
||||
}) {
|
||||
const { page, api, seed, mobile } = options;
|
||||
const { settings } = await api.getUserSettings();
|
||||
const baseline =
|
||||
typeof settings.confirm_task_archive === "boolean" ? settings.confirm_task_archive : undefined;
|
||||
await api.saveUserSettings({ confirm_task_archive: true });
|
||||
const taskOptions = { workflow_id: seed.workflowId, workflow_step_id: seed.startStepId };
|
||||
const nav = await api.seedTask(seed.workspaceId, "Keep selected", taskOptions);
|
||||
const target = await api.seedTask(seed.workspaceId, "Archive immediately", taskOptions);
|
||||
const path = `**/api/v1/tasks/${target.task_id}/archive`;
|
||||
let pending: Route | null = null;
|
||||
const handler = (route: Route) => {
|
||||
pending = route;
|
||||
};
|
||||
await page.route(path, handler);
|
||||
const session = new SessionPage(page);
|
||||
const sheet = page.getByRole("dialog", { name: "Tasks", exact: true });
|
||||
const rows = () => (mobile ? sheet : session.sidebar).getByTestId("sidebar-task-item");
|
||||
const targetRow = () => rows().filter({ hasText: "Archive immediately" });
|
||||
const press = async (locator: ReturnType<Page["getByTestId"]>) => {
|
||||
if (mobile) await locator.tap();
|
||||
else await locator.click();
|
||||
};
|
||||
const openPicker = async () => {
|
||||
if (mobile && !(await sheet.isVisible())) await page.getByTestId("mobile-session-menu").tap();
|
||||
};
|
||||
const openArchive = async () => {
|
||||
await openPicker();
|
||||
if (mobile) {
|
||||
await targetRow().getByRole("button", { name: "Task actions" }).tap();
|
||||
await page.getByRole("menuitem", { name: "Archive", exact: true }).tap();
|
||||
await expect(sheet).toBeHidden();
|
||||
} else {
|
||||
await session.openSidebarMenuAndClick("Archive immediately", "Archive");
|
||||
}
|
||||
};
|
||||
try {
|
||||
await page.goto(`/t/${nav.task_id}`);
|
||||
await session.waitForLoad();
|
||||
await openArchive();
|
||||
await press(page.getByRole("button", { name: "Cancel", exact: true }));
|
||||
await openPicker();
|
||||
await expect(targetRow()).toBeVisible();
|
||||
expect(pending).toBeNull();
|
||||
|
||||
await openArchive();
|
||||
await press(page.getByTestId("archive-task-confirm"));
|
||||
await expect.poll(() => pending !== null).toBe(true);
|
||||
if (mobile) await page.getByTestId("mobile-session-menu").tap();
|
||||
await expect(targetRow()).toBeVisible();
|
||||
await expect(targetRow()).toHaveAttribute("aria-busy", "true");
|
||||
await expect(targetRow()).toHaveClass(/opacity-60/);
|
||||
await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible();
|
||||
await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport();
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${nav.task_id}$`));
|
||||
await (mobile ? sheet : session.sidebar).screenshot({ path: options.screenshotPath });
|
||||
await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport();
|
||||
await pending!.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Archive unavailable" }),
|
||||
});
|
||||
pending = null;
|
||||
if (mobile) {
|
||||
await openPicker();
|
||||
} else {
|
||||
await expect(page.getByText("Failed to archive task", { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(targetRow()).toBeVisible();
|
||||
await expect(targetRow()).not.toHaveAttribute("aria-busy");
|
||||
await expect(targetRow().getByTestId("task-state-archive-pending")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(new RegExp(`/t/${nav.task_id}$`));
|
||||
|
||||
await openArchive();
|
||||
await press(page.getByTestId("archive-task-confirm"));
|
||||
await expect.poll(() => pending !== null).toBe(true);
|
||||
if (mobile) await page.getByTestId("mobile-session-menu").tap();
|
||||
await expect(targetRow()).toBeVisible();
|
||||
await expect(targetRow()).toHaveAttribute("aria-busy", "true");
|
||||
await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible();
|
||||
await pending!.continue();
|
||||
pending = null;
|
||||
await expect(
|
||||
page.getByTestId("toast-message").filter({ hasText: "Archived 1 task." }),
|
||||
).toBeVisible();
|
||||
await expect(targetRow()).toHaveCount(0);
|
||||
} finally {
|
||||
if (pending) await (pending as Route).abort();
|
||||
await page.unroute(path, handler);
|
||||
await api.saveUserSettings({ confirm_task_archive: baseline });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { checkImmediateArchive } from "./sidebar-immediate-archive-helpers";
|
||||
|
||||
test("sidebar shows archive progress before removal and recovers on failure", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
await checkImmediateArchive({
|
||||
page: testPage,
|
||||
api: apiClient,
|
||||
seed: seedData,
|
||||
mobile: false,
|
||||
screenshotPath: testInfo.outputPath("pending-archive.png"),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useStore } from "zustand";
|
||||
import { createAppStore, type AppState } from "@/lib/state/store";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { useWorkspaceSidebarTasks } from "./use-workspace-sidebar-tasks";
|
||||
|
||||
let store: ReturnType<typeof createAppStore>;
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: AppState) => unknown) => useStore(store, selector),
|
||||
useAppStoreApi: () => store,
|
||||
}));
|
||||
vi.mock("./use-all-workflow-snapshots", () => ({ useAllWorkflowSnapshots: () => {} }));
|
||||
vi.mock("@/lib/api", () => ({ fetchTask: vi.fn(), listTaskSessions: vi.fn() }));
|
||||
vi.mock("@/lib/routing/client-router", () => ({ softNavigate: vi.fn() }));
|
||||
vi.mock("@/lib/state/dockview-store", () => ({ performLayoutSwitch: vi.fn() }));
|
||||
|
||||
function task(id: string, parentTaskId?: string) {
|
||||
return {
|
||||
id,
|
||||
parentTaskId,
|
||||
workspaceId: "ws",
|
||||
workflowId: "wf",
|
||||
workflowStepId: "step",
|
||||
title: id,
|
||||
position: 0,
|
||||
};
|
||||
}
|
||||
function setTasks(tasks = [task("a"), task("b"), task("child", "a")]) {
|
||||
store.setState((state) => ({
|
||||
kanban: { ...state.kanban, workflowId: "wf", tasks },
|
||||
kanbanMulti: {
|
||||
...state.kanbanMulti,
|
||||
snapshots: { wf: { workflowId: "wf", workflowName: "Workflow", steps: [], tasks } },
|
||||
},
|
||||
}));
|
||||
}
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function setup() {
|
||||
return renderHook(() => ({
|
||||
sidebar: useWorkspaceSidebarTasks("ws"),
|
||||
removal: useTaskRemoval({ store, stayOnListing: true }),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createAppStore();
|
||||
store.setState((state) => ({
|
||||
workflows: { ...state.workflows, items: [{ id: "wf", workspaceId: "ws", name: "Workflow" }] },
|
||||
workspaces: { ...state.workspaces, activeId: "ws" },
|
||||
}));
|
||||
setTasks();
|
||||
});
|
||||
|
||||
describe("sidebar pending archive projection", () => {
|
||||
// @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3
|
||||
it("marks before mutation settles and restores latest data after rejection", async () => {
|
||||
const view = setup();
|
||||
const pending = deferred();
|
||||
const sibling = view.result.current.sidebar.allTasks[1];
|
||||
let operation!: Promise<unknown>;
|
||||
act(() => {
|
||||
operation = view.result.current.removal.runTaskRemovalBatch("archive", [
|
||||
{ taskId: "a", mutate: () => pending.promise },
|
||||
]);
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"]));
|
||||
expect(view.result.current.sidebar.allTasks[1]).toBe(sibling);
|
||||
expect(store.getState().kanban.tasks).toHaveLength(3);
|
||||
act(() => setTasks([{ ...task("a"), title: "New title" }, task("b"), task("child", "a")]));
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.title)).toEqual([
|
||||
"New title",
|
||||
"b",
|
||||
"child",
|
||||
]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"]));
|
||||
await act(async () => {
|
||||
pending.reject(new Error("refused"));
|
||||
await operation;
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.title)).toEqual([
|
||||
"New title",
|
||||
"b",
|
||||
"child",
|
||||
]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("marks a bulk cascade and only retains failed targets", async () => {
|
||||
const view = setup();
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let operation!: Promise<unknown>;
|
||||
act(() => {
|
||||
operation = view.result.current.removal.runTaskRemovalBatch(
|
||||
"archive",
|
||||
[
|
||||
{ taskId: "a", mutate: () => first.promise },
|
||||
{ taskId: "b", mutate: () => second.promise },
|
||||
],
|
||||
{ cascade: true },
|
||||
);
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a", "b", "child"]));
|
||||
await act(async () => {
|
||||
first.resolve();
|
||||
second.reject(new Error("refused"));
|
||||
await operation;
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["b"]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar archive compatibility", () => {
|
||||
it("keeps confirmed archived rows visible and respects later navigation", () => {
|
||||
const view = setup();
|
||||
act(() => {
|
||||
store.getState().beginTaskRemoval({
|
||||
action: "archive",
|
||||
workspaceId: "ws",
|
||||
taskIds: ["a"],
|
||||
requestIds: ["a"],
|
||||
departure: null,
|
||||
});
|
||||
store.getState().setActiveTask("b");
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"]));
|
||||
act(() => {
|
||||
const state = store.getState();
|
||||
const tasks = state.kanban.tasks.map((row) =>
|
||||
row.id === "a" ? { ...row, isArchived: true } : row,
|
||||
);
|
||||
store.setState({
|
||||
kanban: { ...state.kanban, tasks },
|
||||
kanbanMulti: {
|
||||
...state.kanbanMulti,
|
||||
snapshots: { wf: { ...state.kanbanMulti.snapshots.wf, tasks } },
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.find((row) => row.id === "a")?.isArchived).toBe(
|
||||
true,
|
||||
);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set());
|
||||
expect(store.getState().tasks.activeTaskId).toBe("b");
|
||||
});
|
||||
|
||||
it("removes the last row when success releases pending intent", async () => {
|
||||
setTasks([task("a")]);
|
||||
const view = setup();
|
||||
const pending = deferred();
|
||||
let operation!: Promise<unknown>;
|
||||
act(() => {
|
||||
operation = view.result.current.removal.runTaskRemovalBatch("archive", [
|
||||
{ taskId: "a", mutate: () => pending.promise },
|
||||
]);
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks).toHaveLength(1);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"]));
|
||||
await act(async () => {
|
||||
pending.resolve();
|
||||
await operation;
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks).toHaveLength(0);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set());
|
||||
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
|
||||
});
|
||||
|
||||
it("does not hide delete targets or another workspace's operation", () => {
|
||||
const view = setup();
|
||||
act(() => {
|
||||
store.getState().beginTaskRemoval({
|
||||
action: "delete",
|
||||
workspaceId: "ws",
|
||||
taskIds: ["a"],
|
||||
requestIds: ["a"],
|
||||
departure: null,
|
||||
});
|
||||
store.getState().beginTaskRemoval({
|
||||
action: "archive",
|
||||
workspaceId: "other",
|
||||
taskIds: ["b"],
|
||||
requestIds: ["b"],
|
||||
departure: null,
|
||||
});
|
||||
});
|
||||
expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]);
|
||||
expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ type Snapshot = {
|
||||
};
|
||||
|
||||
type MockState = {
|
||||
taskRemoval: {
|
||||
pendingTokenByTaskId: Record<string, string>;
|
||||
operationsByToken: Record<string, unknown>;
|
||||
};
|
||||
kanbanMulti: {
|
||||
snapshots: Record<string, Snapshot>;
|
||||
isLoading: boolean;
|
||||
@@ -48,6 +52,7 @@ type MockState = {
|
||||
};
|
||||
|
||||
let mockState: MockState = {
|
||||
taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
@@ -71,6 +76,7 @@ import { mergeSidebarArchivedTasks, useWorkspaceSidebarTasks } from "./use-works
|
||||
|
||||
function setMockState(patch: Partial<MockState>) {
|
||||
mockState = {
|
||||
taskRemoval: { ...mockState.taskRemoval, ...(patch.taskRemoval ?? {}) },
|
||||
kanbanMulti: { ...mockState.kanbanMulti, ...(patch.kanbanMulti ?? {}) },
|
||||
workflows: { ...mockState.workflows, ...(patch.workflows ?? {}) },
|
||||
kanban: { ...mockState.kanban, ...(patch.kanban ?? {}) },
|
||||
@@ -99,6 +105,7 @@ describe("useWorkspaceSidebarTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = {
|
||||
taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
@@ -227,6 +234,7 @@ describe("useWorkspaceSidebarTasks reference stability", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = {
|
||||
taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
@@ -287,6 +295,7 @@ describe("useWorkspaceSidebarTasks reference stability", () => {
|
||||
describe("useWorkspaceSidebarTasks WIP queue", () => {
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
@@ -406,6 +415,7 @@ describe("useWorkspaceSidebarTasks — loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = {
|
||||
taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
|
||||
@@ -12,9 +12,11 @@ import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu"
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { WorkspaceContextReadError } from "@/lib/state/slices/kanban/types";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskRemovalState } from "@/lib/state/task-removal";
|
||||
import { getDestinationQueue, type WipQueueStatus } from "@/lib/kanban/wip-queue";
|
||||
|
||||
export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & {
|
||||
pendingArchiveTaskIds: ReadonlySet<string>;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
wipQueueByTaskId: Map<string, WipQueueStatus>;
|
||||
isLoading: boolean;
|
||||
@@ -29,6 +31,10 @@ export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & {
|
||||
const NOOP_REFRESH = () => {};
|
||||
|
||||
type SidebarTask = AggregatedSidebarTasks["allTasks"][number];
|
||||
type SidebarTaskRemovalProjection = Pick<
|
||||
TaskRemovalState,
|
||||
"pendingTokenByTaskId" | "operationsByToken"
|
||||
>;
|
||||
|
||||
function shallowTaskEqual(previous: SidebarTask, next: SidebarTask): boolean {
|
||||
const previousKeys = Object.keys(previous) as Array<keyof SidebarTask>;
|
||||
@@ -181,6 +187,45 @@ export function mergeSidebarArchivedTasks(
|
||||
return archived.length > 0 ? [...activeTasks, ...archived] : activeTasks;
|
||||
}
|
||||
|
||||
function useSidebarTaskProjection(
|
||||
activeTasks: SidebarTask[],
|
||||
archivedTasks: KanbanState["tasks"],
|
||||
workspaceId: string | null,
|
||||
needsArchivedTasks: boolean,
|
||||
taskRemoval: SidebarTaskRemovalProjection,
|
||||
) {
|
||||
const { operationsByToken, pendingTokenByTaskId } = taskRemoval;
|
||||
const previousTasksRef = useRef<SidebarTask[]>([]);
|
||||
const merged = useMemo(
|
||||
() => mergeSidebarArchivedTasks(activeTasks, archivedTasks, workspaceId, needsArchivedTasks),
|
||||
[activeTasks, archivedTasks, needsArchivedTasks, workspaceId],
|
||||
);
|
||||
const allTasks = useMemo(() => {
|
||||
const shared = reuseUnchangedTasks(previousTasksRef.current, merged);
|
||||
previousTasksRef.current = shared;
|
||||
return shared;
|
||||
}, [merged]);
|
||||
const pendingArchiveTaskIds = useMemo(() => {
|
||||
const pending = new Set<string>();
|
||||
if (!workspaceId) return pending;
|
||||
const activeTaskIds = new Set(
|
||||
allTasks.filter((task) => task.isArchived !== true).map((task) => task.id),
|
||||
);
|
||||
for (const [taskId, token] of Object.entries(pendingTokenByTaskId)) {
|
||||
const operation = operationsByToken[token];
|
||||
if (
|
||||
activeTaskIds.has(taskId) &&
|
||||
operation?.action === "archive" &&
|
||||
operation.workspaceId === workspaceId
|
||||
) {
|
||||
pending.add(taskId);
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}, [allTasks, taskRemoval, workspaceId]);
|
||||
return { allTasks, pendingArchiveTaskIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared data source for the desktop sidebar and the mobile task-switcher sheet.
|
||||
*
|
||||
@@ -212,6 +257,12 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS
|
||||
const needsArchivedTasks = viewRequiresArchivedTasks(effectiveView);
|
||||
const archived = useSidebarArchivedTasks(workspaceId, needsArchivedTasks);
|
||||
|
||||
const pendingTokenByTaskId = useAppStore((state) => state.taskRemoval.pendingTokenByTaskId);
|
||||
const operationsByToken = useAppStore((state) => state.taskRemoval.operationsByToken);
|
||||
const taskRemoval = useMemo(
|
||||
() => ({ operationsByToken, pendingTokenByTaskId }),
|
||||
[operationsByToken, pendingTokenByTaskId],
|
||||
);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
@@ -262,18 +313,13 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS
|
||||
|
||||
const { allSteps, stepsByWorkflowId } = useSharedStepMetadata(aggregated);
|
||||
|
||||
const previousTasksRef = useRef<SidebarTask[]>([]);
|
||||
const allTasks = useMemo(() => {
|
||||
const merged = mergeSidebarArchivedTasks(
|
||||
aggregated.allTasks,
|
||||
archived.tasks,
|
||||
workspaceId,
|
||||
needsArchivedTasks,
|
||||
);
|
||||
const shared = reuseUnchangedTasks(previousTasksRef.current, merged);
|
||||
previousTasksRef.current = shared;
|
||||
return shared;
|
||||
}, [aggregated.allTasks, archived.tasks, needsArchivedTasks, workspaceId]);
|
||||
const { allTasks, pendingArchiveTaskIds } = useSidebarTaskProjection(
|
||||
aggregated.allTasks,
|
||||
archived.tasks,
|
||||
workspaceId,
|
||||
needsArchivedTasks,
|
||||
taskRemoval,
|
||||
);
|
||||
|
||||
const wipQueueByTaskId = useMemo(
|
||||
() => buildWipQueueByTaskId(allTasks, allSteps, stepsByWorkflowId),
|
||||
@@ -299,6 +345,7 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS
|
||||
return {
|
||||
...aggregated,
|
||||
allTasks,
|
||||
pendingArchiveTaskIds,
|
||||
allSteps,
|
||||
stepsByWorkflowId,
|
||||
wipQueueByTaskId,
|
||||
|
||||
Reference in New Issue
Block a user