FN-6998: fix mobile mission detail back navigation
Ensure mobile Missions exits detail tabs back to the mission list before outer navigation. - Track mobile detail visibility instead of selected mission IDs when adding navigation entries. - Avoid duplicate history entries for Structure/Activity tab switches and mission refreshes. - Cover browser back and visible Back button behavior across mobile tabs while desktop stays unchanged. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6998-mobile-mission-back.md | 7 ++ .../dashboard/app/components/MissionManager.tsx | 35 +++++-- .../__tests__/MissionManager.swipe-back.test.tsx | 114 ++++++++++++++++++--- 3 files changed, 131 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-6998 Fusion-Task-Lineage: d3dbf3b7-0195-498f-a57f-ed83c6a422e7
This commit is contained in:
7
.changeset/fn-6998-mobile-mission-back.md
Normal file
7
.changeset/fn-6998-mobile-mission-back.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix mobile Missions back navigation from mission detail tabs.
|
||||||
|
category: fix
|
||||||
|
dev: Tracks mission detail visibility for mobile history entries instead of selected mission IDs.
|
||||||
@@ -2476,22 +2476,37 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
t,
|
t,
|
||||||
);
|
);
|
||||||
|
|
||||||
const previousSelectedMissionIdRef = useRef<string | null>(selectedMission?.id ?? null);
|
const previousMobileDetailVisibleRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousSelectedMissionId = previousSelectedMissionIdRef.current;
|
if (!isActive) {
|
||||||
const currentSelectedMissionId = selectedMission?.id ?? null;
|
previousMobileDetailVisibleRef.current = false;
|
||||||
previousSelectedMissionIdRef.current = currentSelectedMissionId;
|
}
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
if (!isActive || !isMobile || !currentSelectedMissionId || previousSelectedMissionId === currentSelectedMissionId) {
|
useEffect(() => {
|
||||||
|
const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
// Keep the mobile detail flag untouched on desktop so split-pane refreshes do not consume or create mobile nav entries.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MissionManager may already sit behind an App-level modal nav entry.
|
if (!isMobileDetailVisible) {
|
||||||
// On mobile, selecting a mission stacks a view entry on top so back goes
|
previousMobileDetailVisibleRef.current = false;
|
||||||
// detail → list → modal close instead of skipping the in-modal list.
|
return;
|
||||||
pushNav({ type: "view", revert: handleBackToList });
|
}
|
||||||
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]);
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionNavigation 2026-06-24-23:48:
|
||||||
|
Mobile Missions treats Structure and Activity as tabs inside one detail view. Push one view entry when detail becomes visible so browser/Android Back exits to the Missions list before any outer modal or app navigation, and do not push again for tab switches, SSE refreshes, or cached mission rehydration.
|
||||||
|
*/
|
||||||
|
if (!previousMobileDetailVisibleRef.current) {
|
||||||
|
pushNav({ type: "view", revert: handleBackToList });
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMobileDetailVisibleRef.current = true;
|
||||||
|
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission]);
|
||||||
|
|
||||||
const selectedMilestoneTelemetry = useMemo(() => {
|
const selectedMilestoneTelemetry = useMemo(() => {
|
||||||
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MissionManager } from "../MissionManager";
|
import { MissionManager } from "../MissionManager";
|
||||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||||
@@ -8,6 +8,7 @@ const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
|
|||||||
const mockFetchMissions = vi.fn();
|
const mockFetchMissions = vi.fn();
|
||||||
const mockFetchMission = vi.fn();
|
const mockFetchMission = vi.fn();
|
||||||
const mockFetchMissionsHealth = vi.fn();
|
const mockFetchMissionsHealth = vi.fn();
|
||||||
|
const mockFetchMissionEvents = vi.fn();
|
||||||
const mockFetchAssertions = vi.fn();
|
const mockFetchAssertions = vi.fn();
|
||||||
const mockFetchMilestoneValidation = vi.fn();
|
const mockFetchMilestoneValidation = vi.fn();
|
||||||
const mockFetchMilestoneValidationTelemetry = vi.fn();
|
const mockFetchMilestoneValidationTelemetry = vi.fn();
|
||||||
@@ -42,6 +43,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
||||||
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
||||||
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
||||||
|
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
|
||||||
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
|
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
|
||||||
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
|
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
|
||||||
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
|
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
|
||||||
@@ -107,6 +109,18 @@ const rollup = {
|
|||||||
state: "not_started" as const,
|
state: "not_started" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const missionEvents = [
|
||||||
|
{
|
||||||
|
id: "ME-001",
|
||||||
|
missionId: "M-001",
|
||||||
|
eventType: "mission_updated",
|
||||||
|
description: "Activity tab loaded",
|
||||||
|
metadata: null,
|
||||||
|
timestamp: "2026-01-03T00:00:00.000Z",
|
||||||
|
seq: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function HistoryHarness({ children }: { children: ReactNode }) {
|
function HistoryHarness({ children }: { children: ReactNode }) {
|
||||||
const history = useNavigationHistory({ enabled: true });
|
const history = useNavigationHistory({ enabled: true });
|
||||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||||
@@ -121,6 +135,7 @@ describe("MissionManager mobile swipe-back", () => {
|
|||||||
mockFetchMissions.mockResolvedValue(missions);
|
mockFetchMissions.mockResolvedValue(missions);
|
||||||
mockFetchMission.mockResolvedValue(missionDetail);
|
mockFetchMission.mockResolvedValue(missionDetail);
|
||||||
mockFetchMissionsHealth.mockResolvedValue({});
|
mockFetchMissionsHealth.mockResolvedValue({});
|
||||||
|
mockFetchMissionEvents.mockResolvedValue({ events: missionEvents, total: missionEvents.length });
|
||||||
mockFetchAssertions.mockResolvedValue([]);
|
mockFetchAssertions.mockResolvedValue([]);
|
||||||
mockFetchMilestoneValidation.mockResolvedValue(rollup);
|
mockFetchMilestoneValidation.mockResolvedValue(rollup);
|
||||||
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
|
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
|
||||||
@@ -135,11 +150,7 @@ describe("MissionManager mobile swipe-back", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("pushes a mobile nav entry when opening mission detail and popstate returns to the list", async () => {
|
it("pushes a mobile nav entry when opening mission detail and popstate returns to the list", async () => {
|
||||||
render(
|
renderMissionManager();
|
||||||
<HistoryHarness>
|
|
||||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
|
||||||
</HistoryHarness>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userSelectMission();
|
await userSelectMission();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -151,28 +162,77 @@ describe("MissionManager mobile swipe-back", () => {
|
|||||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await expectMissionListVisible();
|
||||||
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not push a nav entry on desktop mission selection", async () => {
|
it("returns from the Activity tab to the mobile mission list on browser back", async () => {
|
||||||
|
renderMissionManager();
|
||||||
|
|
||||||
|
await userSelectMission();
|
||||||
|
await openActivityTab();
|
||||||
|
expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Activity tab loaded")).toBeInTheDocument();
|
||||||
|
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectMissionListVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns from the Activity tab with the visible mobile Back button", async () => {
|
||||||
|
renderMissionManager();
|
||||||
|
|
||||||
|
await userSelectMission();
|
||||||
|
await openActivityTab();
|
||||||
|
fireEvent.click(screen.getByTestId("mission-back-btn"));
|
||||||
|
|
||||||
|
await expectMissionListVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add duplicate mobile nav entries for repeated tab changes", async () => {
|
||||||
|
renderMissionManager();
|
||||||
|
|
||||||
|
await userSelectMission();
|
||||||
|
await openActivityTab();
|
||||||
|
fireEvent.click(screen.getByTestId("mission-tab-structure"));
|
||||||
|
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||||
|
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||||
|
|
||||||
|
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectMissionListVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not push a nav entry on desktop mission selection or tab switching", async () => {
|
||||||
mockViewportMode.mockReturnValue("desktop");
|
mockViewportMode.mockReturnValue("desktop");
|
||||||
|
|
||||||
render(
|
renderMissionManager();
|
||||||
<HistoryHarness>
|
|
||||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
|
||||||
</HistoryHarness>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Database Schema")).toBeInTheDocument();
|
expect(screen.getByText("Database Schema")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument();
|
||||||
|
});
|
||||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderMissionManager() {
|
||||||
|
render(
|
||||||
|
<HistoryHarness>
|
||||||
|
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
||||||
|
</HistoryHarness>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function userSelectMission() {
|
async function userSelectMission() {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
||||||
@@ -182,3 +242,27 @@ async function userSelectMission() {
|
|||||||
expect(mockFetchMission).toHaveBeenCalledWith("M-001", undefined);
|
expect(mockFetchMission).toHaveBeenCalledWith("M-001", undefined);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openActivityTab() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mission-tab-activity")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchMissionEvents).toHaveBeenCalledWith(
|
||||||
|
"M-001",
|
||||||
|
expect.objectContaining({ limit: 50, offset: 0 }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMissionListVisible() {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("mission-activity-tab")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Activity tab loaded")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("API Redesign")).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user