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:
gsxdsm
2026-04-08 22:21:24 -07:00
parent 6250cf4efb
commit 48ece0a265
10 changed files with 418 additions and 110 deletions

View File

@@ -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();
});
});

View File

@@ -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");
}, []);