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:
5
.changeset/archive-all-done.md
Normal file
5
.changeset/archive-all-done.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add "Archive All Done" button to the Done column header. This feature allows users to archive all tasks in the "done" column in a single action, with a confirmation dialog and success toast notification. The bulk archive operation uses concurrent processing for efficiency.
|
||||||
@@ -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", () => {
|
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
|
||||||
it("moveTask from archived → in-progress should fail", async () => {
|
it("moveTask from archived → in-progress should fail", async () => {
|
||||||
const task = await store.createTask({ description: "Test task" });
|
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).
|
* Archive a done task (move from done → archived).
|
||||||
* Logs the action and emits `task:moved` event.
|
* Logs the action and emits `task:moved` event.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function AppInner() {
|
|||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
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
|
// Theme management
|
||||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||||
@@ -216,6 +216,7 @@ function AppInner() {
|
|||||||
onUpdateTask={updateTask}
|
onUpdateTask={updateTask}
|
||||||
onArchiveTask={archiveTask}
|
onArchiveTask={archiveTask}
|
||||||
onUnarchiveTask={unarchiveTask}
|
onUnarchiveTask={unarchiveTask}
|
||||||
|
onArchiveAllDone={archiveAllDone}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ export function unarchiveTask(id: string): Promise<Task> {
|
|||||||
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
|
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> {
|
export function approvePlan(id: string): Promise<Task> {
|
||||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface BoardProps {
|
|||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onArchiveAllDone?: () => Promise<Task[]>;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
|||||||
return previous.every((task, index) => task === next[index]);
|
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const { fetchBatch } = useBatchBadgeFetch();
|
const { fetchBatch } = useBatchBadgeFetch();
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -147,6 +148,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
|||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
||||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||||
|
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
|
|||||||
import { QuickEntryBox } from "./QuickEntryBox";
|
import { QuickEntryBox } from "./QuickEntryBox";
|
||||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
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 PAGINATED_COLUMN_THRESHOLD = 100;
|
||||||
const VISIBLE_TASKS_INITIAL = 50;
|
const VISIBLE_TASKS_INITIAL = 50;
|
||||||
@@ -31,11 +31,12 @@ interface ColumnProps {
|
|||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onArchiveAllDone?: () => Promise<Task[]>;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
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 [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
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));
|
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
||||||
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||||
@@ -127,6 +143,17 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
|||||||
+ New Task
|
+ New Task
|
||||||
</button>
|
</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 && (
|
{isArchived && onToggleCollapse && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-icon btn-sm"
|
className="btn btn-icon btn-sm"
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ vi.mock("../../api", () => ({
|
|||||||
mergeTask: vi.fn(),
|
mergeTask: vi.fn(),
|
||||||
retryTask: vi.fn(),
|
retryTask: vi.fn(),
|
||||||
updateTask: vi.fn(),
|
updateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
||||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||||
|
const mockArchiveAllDone = vi.mocked(api.archiveAllDone);
|
||||||
|
|
||||||
// Mock EventSource
|
// Mock EventSource
|
||||||
class MockEventSource {
|
class MockEventSource {
|
||||||
@@ -643,4 +647,57 @@ describe("useTasks", () => {
|
|||||||
expect(result.current.tasks[0].dependencies).toEqual(["KB-002", "KB-003"]);
|
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 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", () => {
|
describe("PATCH /tasks/:id", () => {
|
||||||
let store: TaskStore;
|
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
|
// Upload attachment
|
||||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user