FN-8448: fix Back navigation from import detail
Ensure browser Back returns GitHub imports from issue detail to the issue list. - Preserve navigation history position when More transitions to Import. - Cover nested detail Back behavior in modal and embedded Import Tasks surfaces. - Add a patch changeset for the navigation fix. Files changed: .../fn-8448-github-import-back-to-issue-list.md | 7 +++ packages/dashboard/app/components/MobileNavBar.tsx | 17 ++++++-- .../__tests__/GitHubImportModal.test.tsx | 50 ++++++++++++++++++++++ .../__tests__/MobileNavBar.swipe-back.test.tsx | 18 +++++++- .../dashboard/app/hooks/useNavigationHistory.ts | 27 +++++++++--- 5 files changed, 108 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8448 Fusion-Task-Lineage: ffea2740-861f-464b-839e-030aef06fd34 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8448-github-import-back-to-issue-list.md
Normal file
7
.changeset/fn-8448-github-import-back-to-issue-list.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Android and browser Back from a GitHub import detail returns to the issue list first.
|
||||||
|
category: fix
|
||||||
|
dev: Nested import detail history entry (FN-8228 seam) fixed so first Back clears selection on modal and embedded Import Tasks surfaces.
|
||||||
@@ -245,8 +245,11 @@ export function MobileNavBar({
|
|||||||
navigationHistory.pushNav({ type: "modal", close: closeMore });
|
navigationHistory.pushNav({ type: "modal", close: closeMore });
|
||||||
}, [closeMore, isMoreOpen, navigationHistory]);
|
}, [closeMore, isMoreOpen, navigationHistory]);
|
||||||
|
|
||||||
const dismissMore = useCallback(() => {
|
const dismissMore = useCallback((forNavigation?: boolean) => {
|
||||||
navigationHistory?.removeNav(closeMore);
|
navigationHistory?.removeNav(
|
||||||
|
closeMore,
|
||||||
|
forNavigation === true ? { preserveHistoryPosition: true } : undefined,
|
||||||
|
);
|
||||||
closeMore();
|
closeMore();
|
||||||
}, [closeMore, navigationHistory]);
|
}, [closeMore, navigationHistory]);
|
||||||
|
|
||||||
@@ -315,9 +318,15 @@ export function MobileNavBar({
|
|||||||
}
|
}
|
||||||
}, [dismissMore, resetSheetDrag]);
|
}, [dismissMore, resetSheetDrag]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImportSwipeBack 2026-07-20-23:12:
|
||||||
|
More actions transition directly into their destination. Preserve the
|
||||||
|
current history position while removing More so its asynchronous back
|
||||||
|
consumption cannot dismiss Import or its nested candidate detail afterward.
|
||||||
|
*/
|
||||||
const handleMoreAction = useCallback(
|
const handleMoreAction = useCallback(
|
||||||
(callback?: () => void) => {
|
(callback?: () => void) => {
|
||||||
dismissMore();
|
dismissMore(true);
|
||||||
callback?.();
|
callback?.();
|
||||||
},
|
},
|
||||||
[dismissMore],
|
[dismissMore],
|
||||||
@@ -521,7 +530,7 @@ export function MobileNavBar({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="mobile-more-sheet-backdrop"
|
className="mobile-more-sheet-backdrop"
|
||||||
onClick={dismissMore}
|
onClick={() => dismissMore()}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
|
|||||||
@@ -283,6 +283,17 @@ describe("GitHubImportModal", () => {
|
|||||||
</MobileNavigationHarness>,
|
</MobileNavigationHarness>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function EmbeddedNavigationHarness({ children, onViewRevert }: { children: ReactNode; onViewRevert: () => void }) {
|
||||||
|
const navigationHistory = useNavigationHistory({ enabled: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigationHistory.pushNav({ type: "view", revert: onViewRevert });
|
||||||
|
return () => navigationHistory.removeNav(onViewRevert);
|
||||||
|
}, [navigationHistory, onViewRevert]);
|
||||||
|
|
||||||
|
return <NavigationHistoryProvider value={navigationHistory}>{children}</NavigationHistoryProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
it("builds a Planning Mode seed with the GitHub issue context", () => {
|
it("builds a Planning Mode seed with the GitHub issue context", () => {
|
||||||
expect(buildIssuePlanningSeed({
|
expect(buildIssuePlanningSeed({
|
||||||
number: 42,
|
number: 42,
|
||||||
@@ -481,6 +492,13 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(screen.queryByTestId(surface === "gitlab" ? "gitlab-import-preview-card" : "github-import-preview-card")).toBeNull();
|
expect(screen.queryByTestId(surface === "gitlab" ? "gitlab-import-preview-card" : "github-import-preview-card")).toBeNull();
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
expect(screen.getByRole("button", {
|
||||||
|
name: surface === "issue"
|
||||||
|
? /Select issue #72/i
|
||||||
|
: surface === "pull"
|
||||||
|
? /Select pull request #73/i
|
||||||
|
: /#73 Swipe GitLab issue/i,
|
||||||
|
})).toBeInTheDocument();
|
||||||
|
|
||||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
@@ -489,6 +507,38 @@ describe("GitHubImportModal", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps embedded Import Tasks mounted until its parent view receives a second Back", async () => {
|
||||||
|
const leaveImportTasks = vi.fn();
|
||||||
|
const originalBack = window.history.back;
|
||||||
|
window.history.back = vi.fn();
|
||||||
|
try {
|
||||||
|
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||||
|
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||||
|
{ number: 75, title: "Embedded swipe issue", body: "Body", html_url: "https://github.com/owner/repo/issues/75", labels: [], state: "open" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EmbeddedNavigationHarness onViewRevert={leaveImportTasks}>
|
||||||
|
<GitHubImportModal isOpen onClose={leaveImportTasks} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />
|
||||||
|
</EmbeddedNavigationHarness>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /Select issue #75/i }));
|
||||||
|
await screen.findByTestId("github-import-preview-card");
|
||||||
|
|
||||||
|
dispatchDetailBack("popstate");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: /Select issue #75/i })).toBeInTheDocument();
|
||||||
|
expect(leaveImportTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||||
|
expect(leaveImportTasks).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
window.history.back = originalBack;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("drains the detail entry when the sheet closes before a rapid reopen", async () => {
|
it("drains the detail entry when the sheet closes before a rapid reopen", async () => {
|
||||||
const originalBack = window.history.back;
|
const originalBack = window.history.back;
|
||||||
window.history.back = vi.fn();
|
window.history.back = vi.fn();
|
||||||
|
|||||||
@@ -132,8 +132,22 @@ describe("MobileNavBar More sheet navigation history", () => {
|
|||||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!));
|
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consumes the More entry on item action", async () => {
|
it("replaces the More entry before opening Import so delayed Back cannot consume it", async () => {
|
||||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-more-item-activity")));
|
const importClose = vi.fn();
|
||||||
|
const props = createDefaultProps();
|
||||||
|
props.onOpenGitHubImport = () => {
|
||||||
|
navigationHistory?.pushNav({ type: "modal", close: importClose });
|
||||||
|
};
|
||||||
|
renderWithHistory(props);
|
||||||
|
await openMore();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-more-item-github"));
|
||||||
|
|
||||||
|
expect(window.history.back).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("mobile-more-item-activity")).toBeNull();
|
||||||
|
|
||||||
|
dispatchPopState(0);
|
||||||
|
expect(importClose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consumes the More entry on Escape", async () => {
|
it("consumes the More entry on Escape", async () => {
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ export interface UseNavigationHistoryResult {
|
|||||||
/** Replace the top-of-stack entry and call history.replaceState. */
|
/** Replace the top-of-stack entry and call history.replaceState. */
|
||||||
replaceCurrent: (entry: NavEntry) => void;
|
replaceCurrent: (entry: NavEntry) => void;
|
||||||
/**
|
/**
|
||||||
* Remove a programmatically-dismissed entry and call history.back so the
|
* Remove a programmatically-dismissed entry. By default history.back()
|
||||||
* browser history entry created by pushNav is consumed as well.
|
* consumes its browser-history entry; `preserveHistoryPosition` instead
|
||||||
|
* replaces the current state for a close-then-navigate transition.
|
||||||
*/
|
*/
|
||||||
removeNav: (closeOrRevert: () => void) => void;
|
removeNav: (
|
||||||
|
closeOrRevert: () => void,
|
||||||
|
options?: { preserveHistoryPosition?: boolean },
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
|
const SELF_POP_FALLBACK_CLEAR_MS = 1_000;
|
||||||
@@ -158,7 +162,7 @@ export function useNavigationHistory(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeNav = useCallback(
|
const removeNav = useCallback(
|
||||||
(closeOrRevert: () => void) => {
|
(closeOrRevert: () => void, options?: { preserveHistoryPosition?: boolean }) => {
|
||||||
if (!enabledRef.current) return;
|
if (!enabledRef.current) return;
|
||||||
|
|
||||||
for (let i = stackRef.current.length - 1; i >= 0; i -= 1) {
|
for (let i = stackRef.current.length - 1; i >= 0; i -= 1) {
|
||||||
@@ -166,6 +170,19 @@ export function useNavigationHistory(
|
|||||||
if (getEntryCallback(entry) !== closeOrRevert) continue;
|
if (getEntryCallback(entry) !== closeOrRevert) continue;
|
||||||
|
|
||||||
stackRef.current.splice(i, 1);
|
stackRef.current.splice(i, 1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:GitHubImportSwipeBack 2026-07-20-23:12:
|
||||||
|
A More-sheet action closes one history surface and immediately opens
|
||||||
|
another. Going back asynchronously after removing More can pop that
|
||||||
|
newly pushed Import/detail entry instead. Keep the browser at its
|
||||||
|
current position and rewrite its nav depth for this atomic transition.
|
||||||
|
*/
|
||||||
|
if (options?.preserveHistoryPosition) {
|
||||||
|
writeHistoryState("replace", stackRef.current.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
selfPopRef.current = true;
|
selfPopRef.current = true;
|
||||||
|
|
||||||
if (selfPopClearTimerRef.current !== null) {
|
if (selfPopClearTimerRef.current !== null) {
|
||||||
@@ -180,7 +197,7 @@ export function useNavigationHistory(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[], // stable — reads from refs
|
[writeHistoryState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register popstate listener. Always registers in browser environments but
|
// Register popstate listener. Always registers in browser environments but
|
||||||
|
|||||||
Reference in New Issue
Block a user