FN-7232: dismiss task detail on mobile swipe-back
Mobile task details now participate in history navigation so native swipe-back dismisses or unwinds the detail modal. - Push a navigation entry whenever task detail opens through the app-level modal path. - Restore the previous task detail snapshot when backing out of nested detail opens before closing the modal. - Add mobile swipe-back coverage for board, list, dock, nested, and explicit close surfaces. - Include a patch changeset for the published Fusion CLI package. Files changed: .changeset/fn-7232-swipe-back-task-detail.md | 7 + packages/dashboard/app/App.tsx | 5 +- packages/dashboard/app/components/AppModals.tsx | 26 +- .../__tests__/TaskDetail.swipe-back.test.tsx | 572 +++++++++++++++++++++ packages/dashboard/vitest.config.ts | 1 + 5 files changed, 605 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7232 Fusion-Task-Lineage: 0291d01c-2008-490c-b0d1-0f0641fbd18c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7232-swipe-back-task-detail.md
Normal file
7
.changeset/fn-7232-swipe-back-task-detail.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Swiping back on mobile now dismisses the open task detail view.
|
||||||
|
category: fix
|
||||||
|
dev: Routes mobile task-detail opens through useNavigationHistory pushNav/removeNav so the native back gesture (popstate) reverts to the originating board/list/dock surface across all detail surfaces.
|
||||||
@@ -781,7 +781,10 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
}, [currentProject?.id]);
|
}, [currentProject?.id]);
|
||||||
|
|
||||||
// History-aware modal open handlers — push nav entries for back-navigation.
|
/*
|
||||||
|
FNXC:TaskDetailSwipeBack 2026-06-29-14:21:
|
||||||
|
Mobile task-detail opens that use the modal path must still push a history entry even when the originating surface is the single-pane mobile list. AppModals owns nested-detail restoration, while this first-open path keeps the baseline dismiss-to-origin callback (`modalManager.closeDetailTask`) for the top-level modal entry.
|
||||||
|
*/
|
||||||
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
|
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
|
||||||
modalManager.openDetailTask(task, tab, opts);
|
modalManager.openDetailTask(task, tab, opts);
|
||||||
pushNav({ type: "modal", close: modalManager.closeDetailTask });
|
pushNav({ type: "modal", close: modalManager.closeDetailTask });
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export function AppModals({
|
|||||||
/*
|
/*
|
||||||
FNXC:TaskDetailBack 2026-06-25-00:00:
|
FNXC:TaskDetailBack 2026-06-25-00:00:
|
||||||
Modal task detail uses the same idempotent close path for explicit Close and browser/Android Back so deep-link URL cleanup is not skipped during popstate. Each open records the pushed history callback because nested task-detail links can create multiple detail entries with otherwise identical close behavior.
|
Modal task detail uses the same idempotent close path for explicit Close and browser/Android Back so deep-link URL cleanup is not skipped during popstate. Each open records the pushed history callback because nested task-detail links can create multiple detail entries with otherwise identical close behavior.
|
||||||
|
|
||||||
|
FNXC:TaskDetailSwipeBack 2026-06-29-14:20:
|
||||||
|
Mobile swipe-back (`popstate`) for modal task detail must step back through nested task-detail opens before dismissing the modal. The latest pushed callback restores the previous task/tab/origin snapshot when one exists, and explicit close falls back to the original first-open callback (`modalManager.closeDetailTask`) so programmatic closes still consume the matching history entry.
|
||||||
*/
|
*/
|
||||||
const closeDetailFromHistory = useCallback(() => {
|
const closeDetailFromHistory = useCallback(() => {
|
||||||
modalManager.closeDetailTask();
|
modalManager.closeDetailTask();
|
||||||
@@ -152,9 +155,9 @@ export function AppModals({
|
|||||||
}, [deepLink, modalManager]);
|
}, [deepLink, modalManager]);
|
||||||
|
|
||||||
const closeDetailWithNav = useCallback(() => {
|
const closeDetailWithNav = useCallback(() => {
|
||||||
removeNav(detailNavCloseRef.current ?? closeDetailFromHistory);
|
removeNav(detailNavCloseRef.current ?? modalManager.closeDetailTask);
|
||||||
closeDetailFromHistory();
|
closeDetailFromHistory();
|
||||||
}, [closeDetailFromHistory, removeNav]);
|
}, [closeDetailFromHistory, modalManager, removeNav]);
|
||||||
|
|
||||||
const closeGroupWithNav = useCallback(() => {
|
const closeGroupWithNav = useCallback(() => {
|
||||||
removeNav(modalManager.closeGroupModal);
|
removeNav(modalManager.closeGroupModal);
|
||||||
@@ -244,13 +247,26 @@ export function AppModals({
|
|||||||
task: Parameters<typeof modalManager.openDetailTask>[0],
|
task: Parameters<typeof modalManager.openDetailTask>[0],
|
||||||
tab?: Parameters<typeof modalManager.openDetailTask>[1],
|
tab?: Parameters<typeof modalManager.openDetailTask>[1],
|
||||||
) => {
|
) => {
|
||||||
|
const previousDetailTask = modalManager.detailTask;
|
||||||
|
const previousDetailTab = modalManager.detailTaskInitialTab;
|
||||||
|
const previousDetailOrigin = modalManager.detailTaskOrigin;
|
||||||
|
const previousNavClose = detailNavCloseRef.current;
|
||||||
|
|
||||||
modalManager.openDetailTask(task, tab);
|
modalManager.openDetailTask(task, tab);
|
||||||
const closeFromHistory = () => {
|
const closeFromHistory = () => {
|
||||||
|
if (detailNavCloseRef.current === closeFromHistory) {
|
||||||
|
detailNavCloseRef.current = previousNavClose;
|
||||||
|
}
|
||||||
|
if (previousDetailTask) {
|
||||||
|
modalManager.openDetailTask(
|
||||||
|
previousDetailTask,
|
||||||
|
previousDetailTab,
|
||||||
|
previousDetailOrigin ? { origin: previousDetailOrigin } : undefined,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
modalManager.closeDetailTask();
|
modalManager.closeDetailTask();
|
||||||
deepLink.handleDetailClose();
|
deepLink.handleDetailClose();
|
||||||
if (detailNavCloseRef.current === closeFromHistory) {
|
|
||||||
detailNavCloseRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
detailNavCloseRef.current = closeFromHistory;
|
detailNavCloseRef.current = closeFromHistory;
|
||||||
pushNav({ type: "modal", close: closeFromHistory });
|
pushNav({ type: "modal", close: closeFromHistory });
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
/**
|
||||||
|
* Focused regression coverage for mobile task-detail swipe-back behavior.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
import type { Settings, Task } from "@fusion/core";
|
||||||
|
import type { ProjectInfo } from "../../api";
|
||||||
|
import { scopedKey } from "../../utils/projectStorage";
|
||||||
|
|
||||||
|
const DEFAULT_PROJECT_ID = "proj-1";
|
||||||
|
|
||||||
|
const defaultSettings: Settings = {
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
recycleWorktrees: false,
|
||||||
|
worktreeInitCommand: "",
|
||||||
|
testCommand: "",
|
||||||
|
buildCommand: "",
|
||||||
|
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true, leftSidebarNav: false, rightDock: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: (...args: any[]) => mockSubscribeSse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
|
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||||
|
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
||||||
|
fetchTasks: vi.fn(() => Promise.resolve([])),
|
||||||
|
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })),
|
||||||
|
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
|
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||||
|
fetchGlobalSettings: vi.fn(() => Promise.resolve({})),
|
||||||
|
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [] })),
|
||||||
|
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
|
||||||
|
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
|
||||||
|
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||||
|
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
||||||
|
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
||||||
|
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
||||||
|
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
||||||
|
globalPause: false,
|
||||||
|
enginePaused: false,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
lastActivityAt: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
fetchScripts: vi.fn(() => Promise.resolve({})),
|
||||||
|
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-1", command: "echo" })),
|
||||||
|
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockCreateTask = vi.fn();
|
||||||
|
const mockUseTasks = vi.fn(() => ({
|
||||||
|
tasks: [],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useTasks", () => ({
|
||||||
|
useTasks: (_options?: any) => mockUseTasks(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useInsights", () => ({
|
||||||
|
useInsights: () => ({
|
||||||
|
sections: [], loading: false, error: null, latestRun: null,
|
||||||
|
isRunInFlight: false, runError: null, refresh: vi.fn(),
|
||||||
|
runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(),
|
||||||
|
dismissStates: new Map(), createTaskStates: new Map(),
|
||||||
|
totalCount: 0, dismissedCount: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useRemoteNodeData", () => ({
|
||||||
|
useRemoteNodeData: vi.fn(() => ({
|
||||||
|
projects: [], tasks: [], health: null, loading: false,
|
||||||
|
error: null, refresh: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
|
||||||
|
useRemoteNodeEvents: vi.fn(() => ({ isConnected: false, lastEvent: null })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useBackgroundSessions", () => ({
|
||||||
|
useBackgroundSessions: vi.fn(() => ({
|
||||||
|
sessions: [], generating: false, needsInput: false,
|
||||||
|
planningSessions: [], dismissSession: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockNodeContextValue = {
|
||||||
|
currentNode: null, currentNodeId: null, isRemote: false,
|
||||||
|
setCurrentNode: vi.fn(), clearCurrentNode: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock("../../context/NodeContext", () => ({
|
||||||
|
NodeProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
useNodeContext: vi.fn(() => mockNodeContextValue),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/model-onboarding-state", () => ({
|
||||||
|
isOnboardingResumable: () => false,
|
||||||
|
getOnboardingResumeStep: () => null,
|
||||||
|
getOnboardingState: () => null,
|
||||||
|
saveOnboardingState: vi.fn(),
|
||||||
|
clearOnboardingState: vi.fn(),
|
||||||
|
isOnboardingCompleted: () => false,
|
||||||
|
markOnboardingCompleted: vi.fn(),
|
||||||
|
markStepSkipped: vi.fn(),
|
||||||
|
getOnboardingCompletedAt: () => null,
|
||||||
|
getSkippedSteps: () => [],
|
||||||
|
getStepData: () => null,
|
||||||
|
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/Board", () => ({
|
||||||
|
Board: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task) => void }) => (
|
||||||
|
<div data-testid="board-view">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<button key={task.id} type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
|
||||||
|
{task.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/ListView", () => ({
|
||||||
|
ListView: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task, options?: { origin?: "list-mobile" }) => void }) => (
|
||||||
|
<div data-testid="list-view">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
type="button"
|
||||||
|
data-testid={`list-open-${task.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (mockUseViewportMode() === "mobile") {
|
||||||
|
onOpenDetail(task, { origin: "list-mobile" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/TaskDetailModal", () => ({
|
||||||
|
TaskDetailModal: ({
|
||||||
|
task,
|
||||||
|
onClose,
|
||||||
|
onOpenDetail,
|
||||||
|
mobileHeaderMode,
|
||||||
|
}: {
|
||||||
|
task: { id: string; title?: string };
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenDetail: (task: { id: string; title: string }) => void;
|
||||||
|
mobileHeaderMode?: "back" | "close";
|
||||||
|
}) => (
|
||||||
|
<div className="modal-overlay open" data-testid="task-detail-modal">
|
||||||
|
<div role="dialog" aria-label={task.title ?? task.id}>
|
||||||
|
<div data-testid="task-detail-mobile-header-mode">{mobileHeaderMode ?? "close"}</div>
|
||||||
|
<button type="button" data-testid="task-detail-close" onClick={onClose}>Close</button>
|
||||||
|
{task.id === "FN-1" ? (
|
||||||
|
<button type="button" data-testid="task-detail-open-nested" onClick={() => onOpenDetail({ id: "FN-2", title: "Nested Task" })}>
|
||||||
|
Open nested
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<h2>{task.title ?? task.id}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
TaskDetailContent: ({
|
||||||
|
task,
|
||||||
|
onBackToBoard,
|
||||||
|
}: {
|
||||||
|
task: { id: string; title?: string };
|
||||||
|
onBackToBoard?: () => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="task-detail-main-panel-content">
|
||||||
|
{onBackToBoard ? <button type="button" data-testid="task-detail-back-to-board" onClick={onBackToBoard}>Back to board</button> : null}
|
||||||
|
<h2>{task.title ?? task.id}</h2>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/SettingsModal", () => ({
|
||||||
|
SettingsModal: ({ onClose }: { onClose: () => void }) => (
|
||||||
|
<div className="modal-overlay open" data-testid="settings-modal">
|
||||||
|
<button type="button" data-testid="settings-close-btn" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
SettingsView: () => <div data-testid="settings-view">Settings</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: () => null }));
|
||||||
|
vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: () => null }));
|
||||||
|
vi.mock("../../components/AgentsView", () => ({ AgentsView: () => <div data-testid="agents-view">Agents</div> }));
|
||||||
|
vi.mock("../../components/ResearchView", () => ({ ResearchView: () => <div data-testid="research-view">Research</div> }));
|
||||||
|
vi.mock("../../components/EvalsView", () => ({ EvalsView: () => <div data-testid="evals-view">Evals</div> }));
|
||||||
|
vi.mock("../../components/TodoView", () => ({ TodoView: () => <div data-testid="todo-view">Todo</div> }));
|
||||||
|
vi.mock("../../components/QuickChatFAB", () => ({ QuickChatFAB: () => null }));
|
||||||
|
vi.mock("../../components/ScriptsModal", () => ({ ScriptsModal: () => null }));
|
||||||
|
vi.mock("../../components/TerminalModal", () => ({ TerminalModal: () => null }));
|
||||||
|
vi.mock("../../components/FileBrowser", () => ({ FileBrowserModal: () => null }));
|
||||||
|
vi.mock("../../components/ActivityLogModal", () => ({ ActivityLogModal: () => null }));
|
||||||
|
vi.mock("../../components/GitManagerModal", () => ({ GitManagerModal: () => null }));
|
||||||
|
vi.mock("../../components/SchedulesModal", () => ({ SchedulesModal: () => null }));
|
||||||
|
vi.mock("../../components/WorkflowEditorModal", () => ({ WorkflowEditorModal: () => null }));
|
||||||
|
vi.mock("../../components/AgentsModal", () => ({ AgentsModal: () => null }));
|
||||||
|
vi.mock("../../components/SubtaskBreakdownModal", () => ({ SubtaskBreakdownModal: () => null }));
|
||||||
|
vi.mock("../../components/UsageModal", () => ({ UsageModal: () => null }));
|
||||||
|
vi.mock("../../components/ModelOnboardingModal", () => ({ ModelOnboardingModal: () => null }));
|
||||||
|
vi.mock("../../components/SetupWizardModal", () => ({ SetupWizardModal: () => null }));
|
||||||
|
vi.mock("../../components/GroupTaskModal", () => ({ GroupTaskModal: () => null }));
|
||||||
|
vi.mock("../../components/ProjectSelector", () => ({ ProjectSelector: () => <div /> }));
|
||||||
|
vi.mock("../../components/ProjectCard", () => ({ ProjectCard: () => <div /> }));
|
||||||
|
vi.mock("../../components/Sidebar", () => ({ Sidebar: () => <div /> }));
|
||||||
|
vi.mock("../../components/Header", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../components/Header")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
Header: () => <div><button title="Settings" type="button">Settings</button></div>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../components/MobileNavBar", () => ({ MobileNavBar: () => null }));
|
||||||
|
vi.mock("../../components/RightDock", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../components/RightDock")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
RightDock: () => null,
|
||||||
|
RightDockExpandModal: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockUseProjects = vi.fn(() => ({ projects: [], loading: false, error: null }));
|
||||||
|
const mockCurrentProjectState = {
|
||||||
|
currentProject: {
|
||||||
|
id: DEFAULT_PROJECT_ID,
|
||||||
|
name: "Test Project",
|
||||||
|
path: "/test",
|
||||||
|
status: "active",
|
||||||
|
isolationMode: "in-process",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
} as ProjectInfo,
|
||||||
|
loading: false,
|
||||||
|
setCurrentProject: vi.fn(),
|
||||||
|
clearCurrentProject: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({ useProjects: () => mockUseProjects() }));
|
||||||
|
vi.mock("../../hooks/useCurrentProject", () => ({
|
||||||
|
useCurrentProject: () => mockCurrentProjectState,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useNodes", () => ({
|
||||||
|
useNodes: vi.fn(() => ({
|
||||||
|
nodes: [], loading: false, error: null,
|
||||||
|
refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseViewportMode = vi.fn(() => "desktop");
|
||||||
|
vi.mock("../../hooks/useViewportMode", () => ({
|
||||||
|
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||||
|
getViewportMode: () => mockUseViewportMode(),
|
||||||
|
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||||
|
useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMobileKeyboard = vi.fn(() => ({
|
||||||
|
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||||
|
useMobileKeyboard: (..._args: unknown[]) => mockUseMobileKeyboard(..._args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { App } from "../../App";
|
||||||
|
|
||||||
|
function makeTask(id: string, title: string): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description: "Test task description",
|
||||||
|
column: "todo",
|
||||||
|
status: "todo",
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchPopState(state: Record<string, unknown> | null) {
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAppAndWait(expectedTestId: string = "board-view") {
|
||||||
|
const result = render(<App />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(expectedTestId)).toBeTruthy();
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Task detail mobile swipe-back", () => {
|
||||||
|
const originalPushState = window.history.pushState;
|
||||||
|
const originalReplaceState = window.history.replaceState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSubscribeSse.mockReset();
|
||||||
|
mockSubscribeSse.mockReturnValue(vi.fn());
|
||||||
|
mockUseTasks.mockReset();
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
mockUseViewportMode.mockReturnValue("mobile");
|
||||||
|
mockUseMobileKeyboard.mockReturnValue({
|
||||||
|
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
|
||||||
|
});
|
||||||
|
localStorage.clear();
|
||||||
|
window.history.pushState = vi.fn();
|
||||||
|
window.history.replaceState = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.pushState = originalPushState;
|
||||||
|
window.history.replaceState = originalReplaceState;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses the board main-panel task detail on mobile popstate", async () => {
|
||||||
|
const task = makeTask("FN-1", "Board Detail");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await renderAppAndWait("board-view");
|
||||||
|
fireEvent.click(screen.getByTestId("open-task-FN-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
|
||||||
|
expect(screen.getByTestId("board-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses the list-mobile task detail on mobile popstate", async () => {
|
||||||
|
const task = makeTask("FN-1", "Mobile List Detail");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
|
||||||
|
|
||||||
|
await renderAppAndWait("list-view");
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("task-detail-mobile-header-mode")).toHaveTextContent("back");
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
|
expect(screen.getByTestId("list-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes a fresh mobile nav entry after close and reopen from the list", async () => {
|
||||||
|
const task = makeTask("FN-1", "Repeat Mobile List Detail");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
|
||||||
|
|
||||||
|
await renderAppAndWait("list-view");
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("task-detail-close"));
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.history.pushState).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
|
expect(screen.getByTestId("list-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the previous modal detail when mobile popstate closes a nested task detail", async () => {
|
||||||
|
const task = makeTask("FN-1", "Parent Task");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task, makeTask("FN-2", "Nested Task")],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
|
||||||
|
|
||||||
|
await renderAppAndWait("list-view");
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("task-detail-open-nested"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(window.history.pushState).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
dispatchPopState({ navIndex: 1 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Nested Task" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops multiple mobile detail entries back to the list target on a rapid Android-style pop", async () => {
|
||||||
|
const task = makeTask("FN-1", "Parent Task");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task, makeTask("FN-2", "Nested Task")],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
|
||||||
|
|
||||||
|
await renderAppAndWait("list-view");
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("task-detail-open-nested"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPopState({ navIndex: 0 });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
|
expect(screen.getByTestId("list-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not push mobile detail history entries on desktop list selection", async () => {
|
||||||
|
mockUseViewportMode.mockReturnValue("desktop");
|
||||||
|
const task = makeTask("FN-1", "Desktop List Detail");
|
||||||
|
mockUseTasks.mockImplementation(() => ({
|
||||||
|
tasks: [task],
|
||||||
|
createTask: mockCreateTask,
|
||||||
|
moveTask: vi.fn(),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
mergeTask: vi.fn(),
|
||||||
|
retryTask: vi.fn(),
|
||||||
|
updateTask: vi.fn(),
|
||||||
|
duplicateTask: vi.fn(),
|
||||||
|
archiveTask: vi.fn(),
|
||||||
|
unarchiveTask: vi.fn(),
|
||||||
|
archiveAllDone: vi.fn(),
|
||||||
|
refreshTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
|
||||||
|
|
||||||
|
await renderAppAndWait("list-view");
|
||||||
|
fireEvent.click(screen.getByTestId("list-open-FN-1"));
|
||||||
|
|
||||||
|
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -185,6 +185,7 @@ const qualityAppComponentTests = [
|
|||||||
"TaskCard.footer-wrap",
|
"TaskCard.footer-wrap",
|
||||||
"TaskChangesTab",
|
"TaskChangesTab",
|
||||||
"TaskComments",
|
"TaskComments",
|
||||||
|
"TaskDetail.swipe-back",
|
||||||
"TaskDetailModal",
|
"TaskDetailModal",
|
||||||
"TaskDetailModal.allow-resurrection",
|
"TaskDetailModal.allow-resurrection",
|
||||||
"TaskDetailModal.create-pr-e2e",
|
"TaskDetailModal.create-pr-e2e",
|
||||||
|
|||||||
Reference in New Issue
Block a user