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,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");
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user