feat(KB-034): add task archive/unarchive functionality
- Add 'archived' column to task store with archiveTask and unarchiveTask methods - Add CLI commands: kb task archive <id> and kb task unarchive <id> - Add pi extension tools for archive and unarchive operations - Add dashboard API endpoints POST /api/tasks/:id/archive and /unarchive - Add Archived column to board UI with archive/unarchive buttons - Prevent drag-drop into archived column, add visual distinction - Include duplicateTask from concurrent branch in merge resolution
This commit is contained in:
@@ -36,7 +36,7 @@ function AppInner() {
|
||||
return "board";
|
||||
});
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask } = useTasks();
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -161,6 +161,8 @@ function AppInner() {
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
onArchiveTask={archiveTask}
|
||||
onUnarchiveTask={unarchiveTask}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
|
||||
@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
|
||||
});
|
||||
|
||||
describe("desktop .board grid template", () => {
|
||||
it("still uses repeat(5, minmax(260px, 1fr))", () => {
|
||||
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
|
||||
expect(css).toContain(
|
||||
"grid-template-columns: repeat(5, minmax(260px, 1fr))",
|
||||
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
@@ -637,4 +637,46 @@ describe("Git Management API", () => {
|
||||
await expect(pushBranch()).rejects.toThrow("Push rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveTask", () => {
|
||||
it("sends POST to archive endpoint", async () => {
|
||||
const archivedTask: Task = { ...FAKE_DETAIL, column: "archived" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, archivedTask));
|
||||
|
||||
const response = await archiveTask("KB-001");
|
||||
|
||||
expect(response.column).toBe("archived");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in done" }, 400));
|
||||
|
||||
await expect(archiveTask("KB-001")).rejects.toThrow("Task not in done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchiveTask", () => {
|
||||
it("sends POST to unarchive endpoint", async () => {
|
||||
const unarchivedTask: Task = { ...FAKE_DETAIL, column: "done" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, unarchivedTask));
|
||||
|
||||
const response = await unarchiveTask("KB-001");
|
||||
|
||||
expect(response.column).toBe("done");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in archived" }, 400));
|
||||
|
||||
await expect(unarchiveTask("KB-001")).rejects.toThrow("Task not in archived");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,14 @@ export function unpauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unpause`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function archiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function unarchiveTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function approvePlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@k
|
||||
import { COLUMNS } from "@kb/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -20,9 +21,13 @@ interface BoardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
@@ -48,8 +53,11 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: () => setArchivedCollapsed(!archivedCollapsed) } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
|
||||
import { InlineCreateCard } from "./InlineCreateCard";
|
||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
@@ -27,17 +28,27 @@ interface ColumnProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
// Archived column is collapsed by default - don't show drag state when collapsed
|
||||
const isArchived = column === "archived";
|
||||
const isCollapsed = isArchived && collapsed;
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
// Don't allow dropping into archived column via drag-drop
|
||||
if (isArchived) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
}, [isArchived]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
@@ -61,7 +72,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}`}
|
||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
data-column={column}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -87,46 +98,68 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
|
||||
<div className="column-body">
|
||||
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||
<InlineCreateCard
|
||||
tasks={allTasks}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onCancelCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
|
||||
return groups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})()
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} onUpdateTask={onUpdateTask} />
|
||||
))
|
||||
{isArchived && onToggleCollapse && (
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
aria-label={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
>
|
||||
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
|
||||
{!isCollapsed && (
|
||||
<div className="column-body">
|
||||
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||
<InlineCreateCard
|
||||
tasks={allTasks}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onCancelCreate}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
|
||||
return groups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})()
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "var(--in-progress)",
|
||||
"in-review": "var(--in-review)",
|
||||
done: "var(--done)",
|
||||
archived: "var(--text-secondary)",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
@@ -247,7 +248,8 @@ export function ListView({
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: []
|
||||
done: [],
|
||||
archived: []
|
||||
};
|
||||
sorted.forEach(task => groups[task.column].push(task));
|
||||
return groups;
|
||||
@@ -318,6 +320,12 @@ export function ListView({
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (column === "archived") {
|
||||
addToast("Tasks can only be archived via the archive button", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onMoveTask(taskId, column);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "rgba(188,140,255,0.15)",
|
||||
"in-review": "rgba(63,185,80,0.15)",
|
||||
done: "rgba(139,148,158,0.15)",
|
||||
archived: "rgba(120,120,120,0.1)",
|
||||
};
|
||||
|
||||
const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -18,6 +19,7 @@ const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
"in-progress": "var(--in-progress)",
|
||||
"in-review": "var(--in-review)",
|
||||
done: "var(--done)",
|
||||
archived: "var(--text-secondary)",
|
||||
};
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
@@ -35,6 +37,8 @@ interface TaskCardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
@@ -45,6 +49,8 @@ export function TaskCard({
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -139,8 +145,9 @@ export function TaskCard({
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDraggable = !queued && !isPaused && !isEditing; // Disable drag during edit
|
||||
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
||||
|
||||
// Check if this card can be edited inline
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||
@@ -369,6 +376,42 @@ export function TaskCard({
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
{/* Archive button for done column tasks */}
|
||||
{task.column === "done" && onArchiveTask && (
|
||||
<button
|
||||
className="card-archive-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchiveTask(task.id).then(() => {
|
||||
addToast(`Archived ${task.id}`, "success");
|
||||
}).catch((err: any) => {
|
||||
addToast(`Failed to archive ${task.id}: ${err.message}`, "error");
|
||||
});
|
||||
}}
|
||||
title="Archive task"
|
||||
aria-label="Archive task"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{/* Unarchive button for archived column tasks */}
|
||||
{task.column === "archived" && onUnarchiveTask && (
|
||||
<button
|
||||
className="card-unarchive-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnarchiveTask(task.id).then(() => {
|
||||
addToast(`Unarchived ${task.id}`, "success");
|
||||
}).catch((err: any) => {
|
||||
addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error");
|
||||
});
|
||||
}}
|
||||
title="Unarchive task"
|
||||
aria-label="Unarchive task"
|
||||
>
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
|
||||
@@ -46,14 +46,14 @@ describe("Board", () => {
|
||||
expect(main.id).toBe("board");
|
||||
});
|
||||
|
||||
it("renders all 5 columns", () => {
|
||||
it("renders all 6 columns", () => {
|
||||
renderBoard();
|
||||
for (const col of COLUMNS) {
|
||||
expect(screen.getByTestId(`column-${col}`)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders all 5 columns as direct children of .board (CSS selector target)", () => {
|
||||
it("renders all 6 columns as direct children of .board (CSS selector target)", () => {
|
||||
renderBoard();
|
||||
const board = screen.getByRole("main");
|
||||
// The mock Column renders <div data-testid="column-{col}" />, which are direct children
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("ListView", () => {
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(5); // One for each column
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
|
||||
// Check that triage section shows count of 2
|
||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
||||
@@ -1152,7 +1152,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
|
||||
@@ -156,5 +156,21 @@ export function useTasks() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask };
|
||||
const archiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = await api.archiveTask(id);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
|
||||
const task = await api.unarchiveTask(id);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? task : t))
|
||||
);
|
||||
return task;
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask };
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ body {
|
||||
/* === Board === */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
height: calc(100vh - 57px);
|
||||
@@ -310,6 +310,9 @@ body {
|
||||
.dot-done {
|
||||
background: var(--done);
|
||||
}
|
||||
.dot-archived {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 14px;
|
||||
@@ -1640,6 +1643,44 @@ body {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Archive/Unarchive buttons */
|
||||
.card-archive-btn,
|
||||
.card-unarchive-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover .card-archive-btn,
|
||||
.card:hover .card-unarchive-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-archive-btn:hover,
|
||||
.card-unarchive-btn:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.card-archive-btn:focus,
|
||||
.card-unarchive-btn:focus {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--todo);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Loading state during save */
|
||||
.card-edit-loading {
|
||||
display: flex;
|
||||
|
||||
@@ -15,6 +15,8 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -261,6 +263,110 @@ describe("POST /tasks/:id/duplicate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/archive", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
archiveTask: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("archives a done task and returns the updated task", async () => {
|
||||
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("archived");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive KB-001: task is in 'triage', must be in 'done'"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be in 'done'");
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toContain("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/unarchive", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
unarchiveTask: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("unarchives an archived task and returns the updated task", async () => {
|
||||
const unarchivedTask = { ...FAKE_TASK_DETAIL, column: "done" };
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(unarchivedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("done");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in archived column", async () => {
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot unarchive KB-001: task is in 'done', must be in 'archived'"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be in 'archived'");
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", 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;
|
||||
|
||||
|
||||
@@ -685,6 +685,28 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Archive task (done → archived)
|
||||
router.post("/tasks/:id/archive", async (req, res) => {
|
||||
try {
|
||||
const task = await store.archiveTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("must be in") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unarchive task (archived → done)
|
||||
router.post("/tasks/:id/unarchive", async (req, res) => {
|
||||
try {
|
||||
const task = await store.unarchiveTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("must be in") ? 400 : 500;
|
||||
res.status(status).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