feat(FN-1328): enable optimistic task detail modal opening
- Update useModalManager to accept Task objects for immediate modal display before full detail loads - Refactor TaskDetailModal to render with basic Task data and load TaskDetail asynchronously - Update TaskCard click handlers to open modal immediately with optimistic data - Update ListView click handler for same optimistic opening behavior - Update AppModals types to support Task | TaskDetail union - Add CSS for modal loading/skeleton states - Add comprehensive tests for TaskDetailModal and useModalManager optimistic flow - Simplify TaskCard and ListView tests to reflect new optimistic opening pattern
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, TaskDetail, ThemeMode } from "@fusion/core";
|
||||
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode } from "@fusion/core";
|
||||
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
|
||||
import type { ModalManager } from "../hooks/useModalManager";
|
||||
import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers";
|
||||
@@ -199,7 +199,7 @@ export function AppModals({
|
||||
onOpenTaskDetail={(taskId) => {
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task) {
|
||||
modalManager.openDetailTask(task as TaskDetail);
|
||||
modalManager.openDetailTask(task);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
|
||||
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
||||
import { batchUpdateTaskModels } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -97,7 +97,7 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
onNewTask?: () => void;
|
||||
@@ -523,15 +523,10 @@ export function ListView({
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
async (task: Task) => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
(task: Task) => {
|
||||
onOpenDetail(task);
|
||||
},
|
||||
[onOpenDetail, addToast, projectId]
|
||||
[onOpenDetail]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
|
||||
@@ -77,7 +77,7 @@ interface TaskCardProps {
|
||||
task: Task;
|
||||
projectId?: string;
|
||||
queued?: boolean;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
@@ -86,7 +86,7 @@ interface TaskCardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: TaskDetail, initialTab: "changes") => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks the mission badge on a task card. */
|
||||
@@ -336,15 +336,10 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [task.id, isFileDrag, addToast]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (isEditing) return; // Don't open detail when editing
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id, projectId);
|
||||
onOpenDetail(detail);
|
||||
} catch {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast, isEditing]);
|
||||
onOpenDetail(task);
|
||||
}, [task, onOpenDetail, isEditing]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent) => {
|
||||
if (touchOpenHandledRef.current) {
|
||||
@@ -591,15 +586,10 @@ function TaskCardComponent({
|
||||
});
|
||||
}, [addToast, onUnarchiveTask, task.id]);
|
||||
|
||||
const handleOpenFiles = useCallback(async (e: React.MouseEvent) => {
|
||||
const handleOpenFiles = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id, projectId);
|
||||
onOpenDetailWithTab?.(detail, "changes");
|
||||
} catch {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
}, [task.id, projectId, onOpenDetailWithTab, addToast]);
|
||||
onOpenDetailWithTab?.(task, "changes");
|
||||
}, [task, onOpenDetailWithTab]);
|
||||
|
||||
const handleToggleSteps = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -170,11 +170,11 @@ function formatBytes(bytes: number): string {
|
||||
type TabId = "definition" | "logs" | "changes" | "commits" | "comments" | "model" | "workflow";
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
tasks?: Task[];
|
||||
onClose: () => void;
|
||||
onOpenDetail: (task: TaskDetail) => void; // For clicking dependencies
|
||||
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
@@ -211,6 +211,49 @@ export function TaskDetailModal({
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
|
||||
// ── Async detail loading ──────────────────────────────────────────────────
|
||||
// When opened optimistically with a Task (no prompt), fetch the full
|
||||
// TaskDetail in the background. The modal renders immediately with the
|
||||
// lightweight data and shows a loading indicator in the spec section.
|
||||
const [fullDetail, setFullDetail] = useState<TaskDetail | null>(() =>
|
||||
"prompt" in task ? (task as TaskDetail) : null,
|
||||
);
|
||||
const [detailLoading, setDetailLoading] = useState(() =>
|
||||
!("prompt" in task),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If the prop already has a prompt field, it's a full TaskDetail
|
||||
if ("prompt" in task) {
|
||||
setFullDetail(task as TaskDetail);
|
||||
setDetailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDetailLoading(true);
|
||||
setFullDetail(null);
|
||||
|
||||
fetchTaskDetail(task.id, projectId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setFullDetail(detail);
|
||||
setDetailLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [task.id, projectId]);
|
||||
|
||||
// Derive a working task that always has all available fields.
|
||||
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
|
||||
const workingTask: TaskDetail = fullDetail ?? { ...task, prompt: "" } as TaskDetail;
|
||||
|
||||
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
@@ -229,7 +272,7 @@ export function TaskDetailModal({
|
||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||
const [isEditingSpec, setIsEditingSpec] = useState(false);
|
||||
const [specEditContent, setSpecEditContent] = useState(task.prompt || "");
|
||||
const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || "");
|
||||
const [specFeedback, setSpecFeedback] = useState("");
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState("");
|
||||
@@ -350,9 +393,9 @@ export function TaskDetailModal({
|
||||
// Reset spec edit state when task changes
|
||||
useEffect(() => {
|
||||
setIsEditingSpec(false);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecEditContent(workingTask.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.id, task.prompt]);
|
||||
}, [task.id, workingTask.prompt]);
|
||||
|
||||
// Note: TaskForm handles auto-focus internally via isActive prop
|
||||
|
||||
@@ -789,17 +832,19 @@ export function TaskDetailModal({
|
||||
const handleSaveSpec = useCallback(async (newContent: string) => {
|
||||
setIsSavingSpec(true);
|
||||
try {
|
||||
await updateTask(task.id, { prompt: newContent }, projectId);
|
||||
await updateTask(workingTask.id, { prompt: newContent }, projectId);
|
||||
addToast("Spec updated", "success");
|
||||
// Update local task data
|
||||
task.prompt = newContent;
|
||||
// Update local detail data
|
||||
if (fullDetail) {
|
||||
fullDetail.prompt = newContent;
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSavingSpec(false);
|
||||
}
|
||||
}, [task, addToast]);
|
||||
}, [workingTask, fullDetail, addToast]);
|
||||
|
||||
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
|
||||
setIsRequestingRevision(true);
|
||||
@@ -822,24 +867,24 @@ export function TaskDetailModal({
|
||||
// Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision)
|
||||
const enterSpecEditMode = useCallback(() => {
|
||||
setIsEditingSpec(true);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecEditContent(workingTask.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.prompt]);
|
||||
}, [workingTask.prompt]);
|
||||
|
||||
const exitSpecEditMode = useCallback(() => {
|
||||
setIsEditingSpec(false);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecEditContent(workingTask.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.prompt]);
|
||||
}, [workingTask.prompt]);
|
||||
|
||||
const handleSaveSpecFromEdit = useCallback(async () => {
|
||||
if (specEditContent === (task.prompt || "")) {
|
||||
if (specEditContent === (workingTask.prompt || "")) {
|
||||
exitSpecEditMode();
|
||||
return;
|
||||
}
|
||||
await handleSaveSpec(specEditContent);
|
||||
setIsEditingSpec(false);
|
||||
}, [specEditContent, task.prompt, handleSaveSpec, exitSpecEditMode]);
|
||||
}, [specEditContent, workingTask.prompt, handleSaveSpec, exitSpecEditMode]);
|
||||
|
||||
const handleRequestRevisionFromEdit = useCallback(async () => {
|
||||
if (!specFeedback.trim()) return;
|
||||
@@ -1196,7 +1241,7 @@ export function TaskDetailModal({
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleSaveSpecFromEdit()}
|
||||
disabled={specEditContent === (task.prompt || "") || isSavingSpec}
|
||||
disabled={specEditContent === (workingTask.prompt || "") || isSavingSpec}
|
||||
>
|
||||
{isSavingSpec ? "Saving…" : "Save"}
|
||||
</button>
|
||||
@@ -1233,10 +1278,12 @@ export function TaskDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : task.prompt ? (
|
||||
) : detailLoading ? (
|
||||
<div className="spec-loading">Loading specification…</div>
|
||||
) : workingTask.prompt ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{task.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1455,7 +1502,7 @@ export function TaskDetailModal({
|
||||
</button>
|
||||
)}
|
||||
{/* Approve/Reject Plan buttons for tasks awaiting approval */}
|
||||
{task.column === "triage" && task.status === "awaiting-approval" && task.prompt && (
|
||||
{task.column === "triage" && task.status === "awaiting-approval" && workingTask.prompt && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
|
||||
Approve Plan
|
||||
|
||||
@@ -204,42 +204,30 @@ describe("ListView", () => {
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenDetail when row is clicked", async () => {
|
||||
it("calls onOpenDetail synchronously with Task when row is clicked", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
const mockDetail: TaskDetail = {
|
||||
...tasks[0],
|
||||
prompt: "Test prompt",
|
||||
};
|
||||
|
||||
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockDetail);
|
||||
|
||||
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
fireEvent.click(row!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
// Should call onOpenDetail synchronously with the Task object (no fetch)
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error toast when fetchTaskDetail fails", async () => {
|
||||
it("does not call fetchTaskDetail on row click", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
|
||||
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
fireEvent.click(row!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Failed to load task details", "error");
|
||||
});
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sorts tasks by ID when ID header is clicked", () => {
|
||||
@@ -2212,12 +2200,6 @@ describe("ListView - Bulk Selection", () => {
|
||||
mockMobileViewport();
|
||||
const task = createMockTask({ id: "FN-001", title: "Open me" });
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
const detail: TaskDetail = {
|
||||
...task,
|
||||
prompt: "Prompt content",
|
||||
};
|
||||
|
||||
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(detail);
|
||||
|
||||
const { container } = renderListView({
|
||||
tasks: [task],
|
||||
@@ -2226,10 +2208,9 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
fireEvent.click(container.querySelector('.list-card[data-id="FN-001"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
|
||||
});
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(detail);
|
||||
// Should call onOpenDetail synchronously with the Task object (no fetch)
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(task);
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("collapses and expands mobile section headers", () => {
|
||||
|
||||
@@ -2532,15 +2532,7 @@ describe("TaskCard detail opening", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens modal when clicking the card body", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
it("opens modal immediately with Task when clicking the card body", async () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const task = makeTask();
|
||||
@@ -2559,21 +2551,12 @@ describe("TaskCard detail opening", () => {
|
||||
const cardTitle = screen.getByText("Test task");
|
||||
fireEvent.click(cardTitle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
// Should call onOpenDetail synchronously with the Task object (no fetch)
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(task);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens modal only once per card click", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
const task = makeTask();
|
||||
|
||||
@@ -2587,11 +2570,9 @@ describe("TaskCard detail opening", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Test task"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Should call onOpenDetail synchronously with the Task object (no fetch)
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(task);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT open modal during vertical scrolling", async () => {
|
||||
|
||||
@@ -4464,4 +4464,217 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimistic opening with Task", () => {
|
||||
beforeEach(async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
});
|
||||
|
||||
it("renders immediately when opened with a Task prop (no prompt)", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValueOnce({
|
||||
id: "FN-200",
|
||||
description: "Optimistic task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
prompt: "# Spec",
|
||||
} as TaskDetail);
|
||||
|
||||
const task: Task = {
|
||||
id: "FN-200",
|
||||
description: "Optimistic task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Modal renders immediately without crashing
|
||||
expect(container.querySelector(".modal-overlay")).toBeTruthy();
|
||||
expect(screen.getByText("FN-200")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls fetchTaskDetail on mount when prop is Task without prompt", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
id: "FN-201",
|
||||
description: "Optimistic task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
prompt: "# Spec",
|
||||
} as TaskDetail);
|
||||
|
||||
const task: Task = {
|
||||
id: "FN-201",
|
||||
description: "Optimistic task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-201", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call fetchTaskDetail when prop is already a TaskDetail with prompt", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
|
||||
const detail: TaskDetail = {
|
||||
id: "FN-202",
|
||||
description: "Full detail task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
prompt: "# Full spec",
|
||||
} as TaskDetail;
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={detail}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Give a tick for any async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalledWith("FN-202", undefined);
|
||||
});
|
||||
|
||||
it("shows loading state in spec area when detailLoading is true", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
// Set up a pending promise so loading state persists
|
||||
mockFetch.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const task: Task = {
|
||||
id: "FN-203",
|
||||
description: "Loading spec test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading specification…")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows spec content after fetchTaskDetail resolves", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
|
||||
const task: Task = {
|
||||
id: "FN-204",
|
||||
description: "Async spec test",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
} as Task;
|
||||
|
||||
const fullDetail: TaskDetail = {
|
||||
...task,
|
||||
prompt: "# Async Spec\n\nThis is the loaded spec content.",
|
||||
} as TaskDetail;
|
||||
|
||||
// Resolve with full detail
|
||||
mockFetch.mockResolvedValueOnce(fullDetail);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={task}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially shows loading
|
||||
expect(screen.getByText("Loading specification…")).toBeDefined();
|
||||
|
||||
// After fetch resolves, spec content appears
|
||||
await waitFor(() => {
|
||||
const markdownBody = container.querySelector(".markdown-body");
|
||||
expect(markdownBody).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Loading indicator should be gone
|
||||
expect(screen.queryByText("Loading specification…")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { useModalManager } from "../useModalManager";
|
||||
import * as api from "../../api";
|
||||
|
||||
@@ -29,9 +29,30 @@ function createTaskDetail(id: string): TaskDetail {
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
steeringComments: [],
|
||||
prompt: "# Task spec",
|
||||
} as TaskDetail;
|
||||
}
|
||||
|
||||
function createTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: "desc",
|
||||
column: "todo",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
columnMovedAt: new Date().toISOString(),
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
attachments: [],
|
||||
size: "M",
|
||||
reviewLevel: 1,
|
||||
steeringComments: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("useModalManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -179,4 +200,58 @@ describe("useModalManager", () => {
|
||||
expect(result.current.settingsOpen).toBe(false);
|
||||
expect(result.current.settingsInitialSection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts plain Task object for optimistic modal opening", () => {
|
||||
const task = createTask("FN-456");
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openDetailTask(task);
|
||||
});
|
||||
|
||||
expect(result.current.detailTask?.id).toBe("FN-456");
|
||||
// Should not have prompt field (plain Task)
|
||||
expect("prompt" in (result.current.detailTask as Record<string, unknown>)).toBe(false);
|
||||
expect(result.current.detailTaskInitialTab).toBe("definition");
|
||||
});
|
||||
|
||||
it("accepts plain Task object in openDetailWithChangesTab", () => {
|
||||
const task = createTask("FN-789");
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openDetailWithChangesTab(task);
|
||||
});
|
||||
|
||||
expect(result.current.detailTask?.id).toBe("FN-789");
|
||||
expect(result.current.detailTaskInitialTab).toBe("changes");
|
||||
});
|
||||
|
||||
it("holds Task object in detailTask state correctly", () => {
|
||||
const task = createTask("FN-100");
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openDetailTask(task);
|
||||
});
|
||||
|
||||
// State should hold the Task object with all its fields
|
||||
const detailTask = result.current.detailTask;
|
||||
expect(detailTask).not.toBeNull();
|
||||
expect(detailTask!.id).toBe("FN-100");
|
||||
expect(detailTask!.title).toBe("Task FN-100");
|
||||
expect(detailTask!.column).toBe("todo");
|
||||
|
||||
// Can be closed and state resets
|
||||
act(() => {
|
||||
result.current.closeDetailTask();
|
||||
});
|
||||
expect(result.current.detailTask).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ export interface ModalManager {
|
||||
isSubtaskOpen: boolean;
|
||||
subtaskInitialDescription: string | null;
|
||||
subtaskResumeSessionId: string | undefined;
|
||||
detailTask: TaskDetail | null;
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
detailTask: (Task | TaskDetail) | null;
|
||||
detailTaskInitialTab: DetailTaskTab;
|
||||
settingsOpen: boolean;
|
||||
settingsInitialSection: SectionId | undefined;
|
||||
@@ -67,8 +68,8 @@ export interface ModalManager {
|
||||
openSubtaskWithSession: (sessionId: string) => void;
|
||||
closeSubtask: () => void;
|
||||
|
||||
openDetailTask: (task: TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
openDetailWithChangesTab: (task: TaskDetail) => void;
|
||||
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
openDetailWithChangesTab: (task: Task | TaskDetail) => void;
|
||||
updateDetailTask: (updated: Partial<TaskDetail>) => void;
|
||||
closeDetailTask: () => void;
|
||||
|
||||
@@ -137,7 +138,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
|
||||
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
|
||||
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab>("definition");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
@@ -218,11 +220,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
}, []);
|
||||
|
||||
const openDetailTask = useCallback((task: TaskDetail, initialTab: DetailTaskTab = "definition") => {
|
||||
const openDetailTask = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "definition") => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab(initialTab);
|
||||
}, []);
|
||||
const openDetailWithChangesTab = useCallback((task: TaskDetail) => {
|
||||
const openDetailWithChangesTab = useCallback((task: Task | TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
setDetailTaskInitialTab("changes");
|
||||
}, []);
|
||||
|
||||
@@ -4162,6 +4162,30 @@ body {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spec-loading {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spec-loading::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* === Markdown Prose === */
|
||||
.markdown-body {
|
||||
font-size: 13px;
|
||||
|
||||
Reference in New Issue
Block a user