feat(HAI-057): add task dependency editing to dashboard UI
- Add dependencies field support to store updateTask method - Wire dependencies through PATCH route and client API - Add dependency editing UI (add/remove) to TaskDetailModal - Pass tasks prop from App to TaskDetailModal for dependency picker - Add tests for dependency CRUD in modal and API layer
This commit is contained in:
@@ -74,6 +74,7 @@ function AppInner() {
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
tasks={tasks}
|
||||
onClose={handleDetailClose}
|
||||
onMoveTask={moveTask}
|
||||
onDeleteTask={deleteTask}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail } from "./api";
|
||||
import type { TaskDetail } from "@hai/core";
|
||||
import { fetchTaskDetail, updateTask } from "./api";
|
||||
import type { Task, TaskDetail } from "@hai/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
id: "HAI-001",
|
||||
@@ -63,3 +63,42 @@ describe("fetchTaskDetail", () => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "HAI-001",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: ["HAI-002"],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("sends PATCH with dependencies and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await updateTask("HAI-001", { dependencies: ["HAI-002"] });
|
||||
|
||||
expect(result.dependencies).toEqual(["HAI-002"]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/HAI-001", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ dependencies: ["HAI-002"] }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }));
|
||||
|
||||
await expect(updateTask("HAI-001", { dependencies: [] })).rejects.toThrow("Not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,13 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[] }): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
export function moveTask(id: string, column: Column): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/move`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
||||
import { uploadAttachment, deleteAttachment } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
@@ -29,6 +29,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
tasks?: Task[];
|
||||
onClose: () => void;
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
@@ -37,8 +38,13 @@ interface TaskDetailModalProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
tasks = [],
|
||||
onClose,
|
||||
onMoveTask,
|
||||
onDeleteTask,
|
||||
@@ -48,6 +54,8 @@ export function TaskDetailModal({
|
||||
}: TaskDetailModalProps) {
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -181,6 +189,30 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, addToast]);
|
||||
|
||||
const handleAddDep = useCallback(async (depId: string) => {
|
||||
const newDeps = [...dependencies, depId];
|
||||
setDependencies(newDeps);
|
||||
try {
|
||||
await updateTask(task.id, { dependencies: newDeps });
|
||||
} catch (err: any) {
|
||||
setDependencies(dependencies);
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, dependencies, addToast]);
|
||||
|
||||
const handleRemoveDep = useCallback(async (depId: string) => {
|
||||
const newDeps = dependencies.filter((d) => d !== depId);
|
||||
setDependencies(newDeps);
|
||||
try {
|
||||
await updateTask(task.id, { dependencies: newDeps });
|
||||
} catch (err: any) {
|
||||
setDependencies(dependencies);
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, dependencies, addToast]);
|
||||
|
||||
const availableTasks = tasks.filter((t) => t.id !== task.id && !dependencies.includes(t.id));
|
||||
|
||||
const transitions = VALID_TRANSITIONS[task.column] || [];
|
||||
|
||||
return (
|
||||
@@ -286,16 +318,66 @@ export function TaskDetailModal({
|
||||
{uploading ? "Uploading…" : "Attach Screenshot"}
|
||||
</button>
|
||||
</div>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="detail-deps">
|
||||
<h4>Dependencies</h4>
|
||||
<div className="detail-deps">
|
||||
<h4>Dependencies</h4>
|
||||
{dependencies.length > 0 ? (
|
||||
<ul className="detail-dep-list">
|
||||
{task.dependencies.map((dep) => (
|
||||
<li key={dep}>{dep}</li>
|
||||
{dependencies.map((dep) => (
|
||||
<li key={dep}>
|
||||
{dep}
|
||||
<button
|
||||
className="dep-remove-btn"
|
||||
onClick={() => handleRemoveDep(dep)}
|
||||
title={`Remove dependency ${dep}`}
|
||||
style={{
|
||||
marginLeft: "6px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-secondary, #888)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no dependencies)</div>
|
||||
)}
|
||||
<div className="dep-trigger-wrap" style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={() => setShowDepDropdown((v) => !v)}
|
||||
>
|
||||
Add Dependency
|
||||
</button>
|
||||
{showDepDropdown && (
|
||||
<div className="dep-dropdown">
|
||||
{availableTasks.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No available tasks</div>
|
||||
) : (
|
||||
availableTasks.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="dep-dropdown-item"
|
||||
onClick={() => {
|
||||
handleAddDep(t.id);
|
||||
setShowDepDropdown(false);
|
||||
}}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section detail-activity">
|
||||
<h4>Activity</h4>
|
||||
{task.log && task.log.length > 0 ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -394,6 +395,95 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders (no dependencies) when dependencies is empty", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ dependencies: [] })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("(no dependencies)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders dependency list when dependencies exist", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("HAI-001")).toBeTruthy();
|
||||
expect(screen.getByText("HAI-002")).toBeTruthy();
|
||||
expect(screen.queryByText("(no dependencies)")).toBeNull();
|
||||
});
|
||||
|
||||
it("can add a dependency via the dropdown", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const allTasks: Task[] = [
|
||||
{ id: "HAI-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||
{ id: "HAI-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ dependencies: [] })}
|
||||
tasks={allTasks}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Add Dependency"));
|
||||
// Should show HAI-001 in the dropdown but not HAI-099 (self is excluded)
|
||||
const dropdown = document.querySelector(".dep-dropdown")!;
|
||||
expect(dropdown).toBeTruthy();
|
||||
expect(dropdown.textContent).toContain("HAI-001");
|
||||
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByText("HAI-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-001"] });
|
||||
});
|
||||
});
|
||||
|
||||
it("can remove a dependency", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const removeButtons = screen.getAllByTitle(/Remove dependency/);
|
||||
fireEvent.click(removeButtons[0]); // Remove HAI-001
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("activity list does not have nested scroll constraints", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -199,6 +199,55 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("forwards dependencies to store.updateTask", async () => {
|
||||
const updatedTask = { ...FAKE_TASK_DETAIL, dependencies: ["HAI-002"] };
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ dependencies: ["HAI-002"] }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
dependencies: ["HAI-002"],
|
||||
});
|
||||
expect(res.body.dependencies).toEqual(["HAI-002"]);
|
||||
});
|
||||
|
||||
it("forwards title and description without dependencies", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ title: "New" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
|
||||
title: "New",
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
dependencies: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachment routes", () => {
|
||||
const FAKE_ATTACHMENT: TaskAttachment = {
|
||||
filename: "1234-screenshot.png",
|
||||
|
||||
@@ -194,11 +194,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Update task
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const { title, description, prompt } = req.body;
|
||||
const { title, description, prompt, dependencies } = req.body;
|
||||
const task = await store.updateTask(req.params.id, {
|
||||
title,
|
||||
description,
|
||||
prompt,
|
||||
dependencies,
|
||||
});
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user