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,
|
||||
);
|
||||
|
||||
const previousSelectedMissionIdRef = useRef<string | null>(selectedMission?.id ?? null);
|
||||
const previousMobileDetailVisibleRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSelectedMissionId = previousSelectedMissionIdRef.current;
|
||||
const currentSelectedMissionId = selectedMission?.id ?? null;
|
||||
previousSelectedMissionIdRef.current = currentSelectedMissionId;
|
||||
if (!isActive) {
|
||||
previousMobileDetailVisibleRef.current = false;
|
||||
}
|
||||
}, [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;
|
||||
}
|
||||
|
||||
// MissionManager may already sit behind an App-level modal nav entry.
|
||||
// On mobile, selecting a mission stacks a view entry on top so back goes
|
||||
// detail → list → modal close instead of skipping the in-modal list.
|
||||
pushNav({ type: "view", revert: handleBackToList });
|
||||
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]);
|
||||
if (!isMobileDetailVisible) {
|
||||
previousMobileDetailVisibleRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
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(() => {
|
||||
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { MissionManager } from "../MissionManager";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
@@ -8,6 +8,7 @@ const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
|
||||
const mockFetchMissions = vi.fn();
|
||||
const mockFetchMission = vi.fn();
|
||||
const mockFetchMissionsHealth = vi.fn();
|
||||
const mockFetchMissionEvents = vi.fn();
|
||||
const mockFetchAssertions = vi.fn();
|
||||
const mockFetchMilestoneValidation = vi.fn();
|
||||
const mockFetchMilestoneValidationTelemetry = vi.fn();
|
||||
@@ -42,6 +43,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
||||
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
||||
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
||||
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
|
||||
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
|
||||
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
|
||||
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
|
||||
@@ -107,6 +109,18 @@ const rollup = {
|
||||
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 }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
@@ -121,6 +135,7 @@ describe("MissionManager mobile swipe-back", () => {
|
||||
mockFetchMissions.mockResolvedValue(missions);
|
||||
mockFetchMission.mockResolvedValue(missionDetail);
|
||||
mockFetchMissionsHealth.mockResolvedValue({});
|
||||
mockFetchMissionEvents.mockResolvedValue({ events: missionEvents, total: missionEvents.length });
|
||||
mockFetchAssertions.mockResolvedValue([]);
|
||||
mockFetchMilestoneValidation.mockResolvedValue(rollup);
|
||||
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 () => {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
renderMissionManager();
|
||||
|
||||
await userSelectMission();
|
||||
await waitFor(() => {
|
||||
@@ -151,28 +162,77 @@ describe("MissionManager mobile swipe-back", () => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
||||
await expectMissionListVisible();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
renderMissionManager();
|
||||
|
||||
await waitFor(() => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
function renderMissionManager() {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
}
|
||||
|
||||
async function userSelectMission() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
||||
@@ -182,3 +242,27 @@ async function userSelectMission() {
|
||||
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