FN-5705: wire task-detail opens into navigation history
Ensure task detail modal openings consistently register browser history so Android back swipe/button dismisses the modal from all entry paths. - add a shared openDetailTaskWithNav callback in AppModals that opens task detail and pushes a modal close handler into navigation history - route onboarding view-task, activity log open-task, and task-to-task detail navigation through the new history-aware opener - extend AppModals tests with a navigation history wrapper and popstate assertions for onboarding/activity log/task-to-task flows - document that these task detail entry points now participate in navigation history for consistent Android back behavior Files changed: docs/dashboard-guide.md | 1 + packages/dashboard/app/components/AppModals.tsx | 21 ++++- .../app/components/__tests__/AppModals.test.tsx | 101 ++++++++++++++++++++- 3 files changed, 115 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-5705 Fusion-Task-Lineage: fd0309ae-e0da-4489-95e4-c34699287e7c
This commit is contained in:
@@ -9,6 +9,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
|
||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||
Using Back will first dismiss open modals and then step back through in-app view changes (for example, task detail → board) before leaving the app.
|
||||
This behavior used to be mobile-only, and now applies across all viewports.
|
||||
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
|
||||
|
||||
## Deep Links
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { WorkflowStepManager } from "./WorkflowStepManager";
|
||||
import { AgentListModal } from "./AgentListModal";
|
||||
import { ModelOnboardingModal } from "./ModelOnboardingModal";
|
||||
import { ToastContainer } from "./ToastContainer";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
|
||||
const SetupWizardModal = lazy(() => import("./SetupWizardModal").then((m) => ({ default: m.SetupWizardModal })));
|
||||
const SettingsModal = lazy(() => import("./SettingsModal").then((m) => ({ default: m.SettingsModal })));
|
||||
@@ -104,6 +105,7 @@ export function AppModals({
|
||||
onReopenOnboarding,
|
||||
onOpenApprovals,
|
||||
}: AppModalsProps) {
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
|
||||
const detailTask = modalManager.detailTask
|
||||
? (() => {
|
||||
@@ -136,11 +138,22 @@ export function AppModals({
|
||||
modalManager.openGitHubImport();
|
||||
}, [modalManager]);
|
||||
|
||||
const openDetailTaskWithNav = useCallback(
|
||||
(
|
||||
task: Parameters<typeof modalManager.openDetailTask>[0],
|
||||
tab?: Parameters<typeof modalManager.openDetailTask>[1],
|
||||
) => {
|
||||
modalManager.openDetailTask(task, tab);
|
||||
pushNav({ type: "modal", close: modalManager.closeDetailTask });
|
||||
},
|
||||
[modalManager, pushNav],
|
||||
);
|
||||
|
||||
const handleOnboardingViewTask = useCallback((task: Task) => {
|
||||
setFirstCreatedTask(null);
|
||||
modalManager.closeModelOnboarding();
|
||||
modalManager.openDetailTask(task);
|
||||
}, [modalManager]);
|
||||
openDetailTaskWithNav(task);
|
||||
}, [modalManager, openDetailTaskWithNav]);
|
||||
|
||||
const handleModalCreateWithOnboardingTracking = useCallback(
|
||||
async (input: TaskCreateInput): Promise<Task> => {
|
||||
@@ -172,7 +185,7 @@ export function AppModals({
|
||||
projectId={projectId}
|
||||
tasks={tasks}
|
||||
onClose={deepLink.handleDetailClose}
|
||||
onOpenDetail={modalManager.openDetailTask}
|
||||
onOpenDetail={openDetailTaskWithNav}
|
||||
mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"}
|
||||
onMoveTask={taskOperations.moveTask}
|
||||
onDeleteTask={taskOperations.deleteTask}
|
||||
@@ -320,7 +333,7 @@ export function AppModals({
|
||||
onOpenTaskDetail={(taskId) => {
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task) {
|
||||
modalManager.openDetailTask(task);
|
||||
openDetailTaskWithNav(task);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
|
||||
import { AppModals } from "../AppModals";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
import type { ModalManager } from "../../hooks/useModalManager";
|
||||
import type { Toast } from "../../hooks/useToast";
|
||||
|
||||
@@ -9,7 +11,11 @@ const mockTaskDetailModalProps = vi.fn();
|
||||
vi.mock("../TaskDetailModal", () => ({
|
||||
TaskDetailModal: (props: any) => {
|
||||
mockTaskDetailModalProps(props);
|
||||
return null;
|
||||
return (
|
||||
<button data-testid="task-detail-open-detail" onClick={() => props.onOpenDetail?.({ id: "FN-2", title: "Nested" })}>
|
||||
open detail
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -78,8 +84,16 @@ vi.mock("../SystemStatsModal", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockActivityLogModalProps = vi.fn();
|
||||
vi.mock("../ActivityLogModal", () => ({
|
||||
ActivityLogModal: () => null,
|
||||
ActivityLogModal: (props: any) => {
|
||||
mockActivityLogModalProps(props);
|
||||
return (
|
||||
<button data-testid="activity-log-open-task" onClick={() => props.onOpenTaskDetail?.("FN-1")}>
|
||||
open task detail
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../GitManagerModal", () => ({
|
||||
@@ -102,7 +116,11 @@ const mockModelOnboardingModalProps = vi.fn();
|
||||
vi.mock("../ModelOnboardingModal", () => ({
|
||||
ModelOnboardingModal: (props: any) => {
|
||||
mockModelOnboardingModalProps(props);
|
||||
return null;
|
||||
return (
|
||||
<button data-testid="onboarding-view-task" onClick={() => props.onViewTask?.({ id: "FN-1", title: "Created task" })}>
|
||||
view task
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -135,6 +153,15 @@ vi.mock("../ErrorBoundary", () => ({
|
||||
ModalErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
function NavigationWrapper({ children }: { children: ReactNode }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
||||
return rtlRender(ui, { wrapper: NavigationWrapper, ...options });
|
||||
}
|
||||
|
||||
describe("AppModals", () => {
|
||||
const mockModalManager: ModalManager = {
|
||||
// State
|
||||
@@ -234,6 +261,7 @@ describe("AppModals", () => {
|
||||
mockTaskDetailModalProps.mockClear();
|
||||
mockScheduledTasksModalProps.mockClear();
|
||||
mockModelOnboardingModalProps.mockClear();
|
||||
mockActivityLogModalProps.mockClear();
|
||||
mockSettingsModalProps.mockClear();
|
||||
mockSystemStatsModalProps.mockClear();
|
||||
mockTodoModalProps.mockClear();
|
||||
@@ -543,4 +571,69 @@ describe("AppModals", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task detail history wiring", () => {
|
||||
const commonProps = {
|
||||
projectId: "proj-1",
|
||||
tasks: [{ id: "FN-1", title: "Task one" }],
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
addToast: vi.fn(),
|
||||
toasts: mockToasts,
|
||||
removeToast: vi.fn(),
|
||||
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
||||
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
||||
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
||||
deepLink: { handleDetailClose: vi.fn() },
|
||||
settings: mockSettings,
|
||||
};
|
||||
|
||||
it("pushes history for activity-log open and closes on popstate", async () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
const closeDetailTask = vi.fn();
|
||||
render(
|
||||
<AppModals
|
||||
{...commonProps}
|
||||
modalManager={{ ...mockModalManager, activityLogOpen: true, closeDetailTask }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("activity-log-open-task"));
|
||||
expect(pushStateSpy).toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("pushes history for onboarding view-task open and closes on popstate", async () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
const closeDetailTask = vi.fn();
|
||||
render(
|
||||
<AppModals
|
||||
{...commonProps}
|
||||
modalManager={{ ...mockModalManager, modelOnboardingOpen: true, closeDetailTask }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("onboarding-view-task"));
|
||||
expect(pushStateSpy).toHaveBeenCalled();
|
||||
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("pushes an additional history entry for task-to-task detail navigation", () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
render(
|
||||
<AppModals
|
||||
{...commonProps}
|
||||
modalManager={{ ...mockModalManager, detailTask: { id: "FN-1", title: "Task one" } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-detail-open-detail"));
|
||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockModalManager.openDetailTask).toHaveBeenCalledWith({ id: "FN-2", title: "Nested" }, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user