feat(KB-069): add Archive All Done feature
- Add archiveAllDone method to TaskStore with filtering and batch archive - Add POST /tasks/archive-all-done API endpoint with tests - Add useTasks hook support and API function for archiveAllDone - Add Archive All button to done column header in Board UI - Wire up onArchiveAllDone through Board component hierarchy
This commit is contained in:
@@ -2113,6 +2113,96 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveAllDone", () => {
|
||||
it("archives multiple done tasks", async () => {
|
||||
const task1 = await store.createTask({ description: "Test task 1" });
|
||||
const task2 = await store.createTask({ description: "Test task 2" });
|
||||
const task3 = await store.createTask({ description: "Test task 3" });
|
||||
|
||||
// Move all to done
|
||||
for (const task of [task1, task2, task3]) {
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
}
|
||||
|
||||
const archived = await store.archiveAllDone();
|
||||
|
||||
expect(archived).toHaveLength(3);
|
||||
expect(archived.every((t) => t.column === "archived")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array when no done tasks exist", async () => {
|
||||
const result = await store.archiveAllDone();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits task:moved event for each archived task", async () => {
|
||||
const task1 = await store.createTask({ description: "Test task 1" });
|
||||
const task2 = await store.createTask({ description: "Test task 2" });
|
||||
|
||||
for (const task of [task1, task2]) {
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
}
|
||||
|
||||
const events: any[] = [];
|
||||
store.on("task:moved", (data) => events.push(data));
|
||||
|
||||
await store.archiveAllDone();
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.every((e) => e.from === "done" && e.to === "archived")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not affect tasks in other columns", async () => {
|
||||
const doneTask = await store.createTask({ description: "Done task" });
|
||||
await store.moveTask(doneTask.id, "todo");
|
||||
await store.moveTask(doneTask.id, "in-progress");
|
||||
await store.moveTask(doneTask.id, "in-review");
|
||||
await store.moveTask(doneTask.id, "done");
|
||||
|
||||
const todoTask = await store.createTask({ description: "Todo task" });
|
||||
await store.moveTask(todoTask.id, "todo");
|
||||
|
||||
const inProgressTask = await store.createTask({ description: "In progress task" });
|
||||
await store.moveTask(inProgressTask.id, "todo");
|
||||
await store.moveTask(inProgressTask.id, "in-progress");
|
||||
|
||||
await store.archiveAllDone();
|
||||
|
||||
const fetchedTodo = await store.getTask(todoTask.id);
|
||||
const fetchedInProgress = await store.getTask(inProgressTask.id);
|
||||
|
||||
expect(fetchedTodo.column).toBe("todo");
|
||||
expect(fetchedInProgress.column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("archives only done tasks when mixed columns exist", async () => {
|
||||
const doneTask1 = await store.createTask({ description: "Done task 1" });
|
||||
const doneTask2 = await store.createTask({ description: "Done task 2" });
|
||||
const todoTask = await store.createTask({ description: "Todo task" });
|
||||
|
||||
for (const task of [doneTask1, doneTask2]) {
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
}
|
||||
|
||||
await store.moveTask(todoTask.id, "todo");
|
||||
|
||||
const archived = await store.archiveAllDone();
|
||||
|
||||
expect(archived).toHaveLength(2);
|
||||
expect(archived.map((t) => t.id).sort()).toEqual([doneTask1.id, doneTask2.id].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
|
||||
it("moveTask from archived → in-progress should fail", async () => {
|
||||
const task = await store.createTask({ description: "Test task" });
|
||||
|
||||
@@ -875,6 +875,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive all tasks currently in the "done" column.
|
||||
* Returns an array of archived tasks.
|
||||
*/
|
||||
async archiveAllDone(): Promise<Task[]> {
|
||||
const tasks = await this.listTasks();
|
||||
const doneTasks = tasks.filter((t) => t.column === "done");
|
||||
|
||||
if (doneTasks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Archive all done tasks concurrently
|
||||
const archivedTasks = await Promise.all(
|
||||
doneTasks.map((task) => this.archiveTask(task.id))
|
||||
);
|
||||
|
||||
return archivedTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a done task (move from done → archived).
|
||||
* Logs the action and emits `task:moved` event.
|
||||
|
||||
@@ -46,7 +46,7 @@ function AppInner() {
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks();
|
||||
|
||||
// Theme management
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
@@ -216,6 +216,7 @@ function AppInner() {
|
||||
onUpdateTask={updateTask}
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -123,6 +123,12 @@ export function unarchiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveAllDone(): Promise<Task[]> {
|
||||
return api<{ archived: Task[] }>("/tasks/archive-all-done", { method: "POST" }).then(
|
||||
(response) => response.archived
|
||||
);
|
||||
}
|
||||
|
||||
export function approvePlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface BoardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, searchQuery = "" }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "" }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -147,6 +148,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Archive } from "lucide-react";
|
||||
|
||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||
const VISIBLE_TASKS_INITIAL = 50;
|
||||
@@ -31,11 +31,12 @@ interface ColumnProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -99,6 +100,21 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
||||
}, [tasks.length]);
|
||||
|
||||
const handleArchiveAll = useCallback(async () => {
|
||||
if (!onArchiveAllDone) return;
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const confirmed = window.confirm(`Archive all ${tasks.length} done tasks?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const archived = await onArchiveAllDone();
|
||||
addToast(`Archived ${archived.length} tasks`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to archive tasks", "error");
|
||||
}
|
||||
}, [onArchiveAllDone, tasks.length, addToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
@@ -127,6 +143,17 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
{column === "done" && onArchiveAllDone && (
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={handleArchiveAll}
|
||||
disabled={tasks.length === 0}
|
||||
title="Archive all done tasks"
|
||||
aria-label="Archive all done tasks"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
)}
|
||||
{isArchived && onToggleCollapse && (
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
|
||||
@@ -30,10 +30,14 @@ vi.mock("../../api", () => ({
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
@@ -643,4 +647,57 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].dependencies).toEqual(["KB-002", "KB-003"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveAllDone", () => {
|
||||
it("archives all done tasks and updates local state", async () => {
|
||||
const doneTasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" as Column }),
|
||||
createMockTask({ id: "KB-002", column: "done" as Column }),
|
||||
];
|
||||
const todoTask = createMockTask({ id: "KB-003", column: "todo" as Column });
|
||||
mockFetchTasks.mockResolvedValueOnce([...doneTasks, todoTask]);
|
||||
|
||||
const archivedTasks = [
|
||||
createMockTask({ id: "KB-001", column: "archived" as Column }),
|
||||
createMockTask({ id: "KB-002", column: "archived" as Column }),
|
||||
];
|
||||
mockArchiveAllDone.mockResolvedValueOnce(archivedTasks);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.archiveAllDone();
|
||||
});
|
||||
|
||||
expect(mockArchiveAllDone).toHaveBeenCalled();
|
||||
// Done tasks should be archived
|
||||
expect(result.current.tasks.find((t) => t.id === "KB-001")?.column).toBe("archived");
|
||||
expect(result.current.tasks.find((t) => t.id === "KB-002")?.column).toBe("archived");
|
||||
// Todo task should remain unchanged
|
||||
expect(result.current.tasks.find((t) => t.id === "KB-003")?.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("returns empty array when no done tasks exist", async () => {
|
||||
const todoTask = createMockTask({ id: "KB-001", column: "todo" as Column });
|
||||
mockFetchTasks.mockResolvedValueOnce([todoTask]);
|
||||
mockArchiveAllDone.mockResolvedValueOnce([]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const archived = await act(async () => {
|
||||
return await result.current.archiveAllDone();
|
||||
});
|
||||
|
||||
expect(archived).toEqual([]);
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,5 +214,18 @@ export function useTasks() {
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask };
|
||||
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||
const archived = await api.archiveAllDone();
|
||||
const normalized = archived.map(normalizeTask);
|
||||
// Update local state by mapping over tasks and updating archived ones
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
const updated = normalized.find((archived) => archived.id === t.id);
|
||||
return updated || t;
|
||||
})
|
||||
);
|
||||
return normalized;
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone };
|
||||
}
|
||||
|
||||
@@ -705,6 +705,63 @@ describe("POST /tasks/:id/unarchive", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/archive-all-done", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
archiveAllDone: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("archives all done tasks and returns the archived array", async () => {
|
||||
const archivedTasks = [
|
||||
{ ...FAKE_TASK_DETAIL, id: "KB-001", column: "archived" },
|
||||
{ ...FAKE_TASK_DETAIL, id: "KB-002", column: "archived" },
|
||||
];
|
||||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTasks);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.archived).toHaveLength(2);
|
||||
expect(res.body.archived[0].column).toBe("archived");
|
||||
expect(res.body.archived[1].column).toBe("archived");
|
||||
expect(store.archiveAllDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty array when no done tasks exist", async () => {
|
||||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.archived).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.archiveAllDone as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/archive-all-done", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -790,6 +790,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Archive all done tasks
|
||||
router.post("/tasks/archive-all-done", async (req, res) => {
|
||||
try {
|
||||
const archived = await store.archiveAllDone();
|
||||
res.json({ archived });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment
|
||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user