FN-8267: dismiss mobile artifact viewers on back
Ensure mobile artifact viewers close before dashboard navigation. - Register mobile viewer entries with navigation history. - Consume entries for header and Escape dismissals. - Cover browser and native Back behavior and document it. Files changed: docs/dashboard-guide.md | 3 +- .../dashboard/app/components/ArtifactsGallery.tsx | 26 +++- .../__tests__/ArtifactsGallery.swipe-back.test.tsx | 170 +++++++++++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8267 Fusion-Task-Lineage: 5a5edb10-b6c5-4b9d-89a8-6623306953f6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -74,9 +74,10 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
|
||||
|
||||
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 before leaving the app.
|
||||
On mobile, an open navigation-bar **More** sheet or mailbox message detail is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes.
|
||||
On mobile, an open navigation-bar **More** sheet, mailbox message detail, or artifact viewer is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes.
|
||||
<!-- FNXC:MobileNavBackDocs 2026-07-16-14:45: The mobile More sheet registers as a navigation modal, so every Back delivery mechanism dismisses it before navigating away. -->
|
||||
<!-- FNXC:MailboxMobileBackDocs 2026-07-16-16:15: A mobile mailbox message detail registers as a navigation modal, so Back returns to the message list instead of navigating away. -->
|
||||
<!-- FNXC:ArtifactViewerMobileBackDocs 2026-07-16-17:15: Mobile artifact viewers register as navigation modals, so every Back delivery mechanism returns the operator to the artifact gallery before changing dashboard views. -->
|
||||
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
|
||||
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./ArtifactsGallery.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import { artifactMediaUrl, artifactMediaUrlWithToken, fetchArtifact, updateArtif
|
||||
import { withTokenHeader } from "../auth";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-15:40:
|
||||
@@ -98,6 +99,7 @@ interface ViewerState {
|
||||
|
||||
export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onOpenTask, onArtifactUpdated }: ArtifactsGalleryProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const navigationHistory = useContext(NavigationHistoryContext);
|
||||
const [categoryFilter, setCategoryFilter] = useState<ArtifactCategoryFilter>("all");
|
||||
const [viewer, setViewer] = useState<ViewerState | null>(null);
|
||||
const viewerReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
@@ -136,6 +138,22 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
|
||||
viewerReturnFocusRef.current = null;
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:ArtifactViewerMobileBack 2026-07-16-17:00:
|
||||
A mobile artifact viewer must dismiss before navigation on iOS swipe-back, Android native Back, and browser Back.
|
||||
Register one stable modal closer for every viewer kind; nullable context keeps provider-less gallery renders working.
|
||||
Programmatic close uses removeNav to consume its pushed entry, while popstate invokes closeViewer directly without a double-back.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMobile || !viewer || !navigationHistory) return;
|
||||
navigationHistory.pushNav({ type: "modal", close: closeViewer });
|
||||
}, [closeViewer, isMobile, navigationHistory, viewer]);
|
||||
|
||||
const dismissViewer = useCallback(() => {
|
||||
navigationHistory?.removeNav(closeViewer);
|
||||
closeViewer();
|
||||
}, [closeViewer, navigationHistory]);
|
||||
|
||||
const visibleCategories = ARTIFACT_CATEGORY_ORDER.filter((category) =>
|
||||
grouped.has(category) && (categoryFilter === "all" || categoryFilter === category));
|
||||
|
||||
@@ -195,10 +213,10 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
|
||||
})}
|
||||
|
||||
{viewer && viewer.kind === "media" && (
|
||||
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
|
||||
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
|
||||
)}
|
||||
{viewer && viewer.kind === "pdf" && (
|
||||
<PdfViewer artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
|
||||
<PdfViewer artifact={viewer.artifact} projectId={projectId} t={t} onClose={dismissViewer} onOpenTask={onOpenTask} />
|
||||
)}
|
||||
{viewer && viewer.kind === "doc" && (
|
||||
<DocViewer
|
||||
@@ -206,7 +224,7 @@ export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onO
|
||||
projectId={projectId}
|
||||
t={t}
|
||||
addToast={addToast}
|
||||
onClose={closeViewer}
|
||||
onClose={dismissViewer}
|
||||
onOpenTask={onOpenTask}
|
||||
onArtifactUpdated={onArtifactUpdated}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ArtifactWithTask } from "@fusion/core";
|
||||
import { ArtifactsGallery } from "../ArtifactsGallery";
|
||||
import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||
artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media?fn_token=test`),
|
||||
fetchArtifact: vi.fn(() => Promise.resolve({})),
|
||||
updateArtifact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../FloatingWindow", () => ({
|
||||
FloatingWindow: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const artifacts: ArtifactWithTask[] = [
|
||||
{
|
||||
id: "image-artifact",
|
||||
type: "image",
|
||||
title: "Image artifact",
|
||||
mimeType: "image/png",
|
||||
uri: "artifacts/image.png",
|
||||
authorId: "agent",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-07-16T12:00:00.000Z",
|
||||
updatedAt: "2026-07-16T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "doc-artifact",
|
||||
type: "document",
|
||||
title: "Document artifact",
|
||||
mimeType: "text/markdown",
|
||||
content: "# Document",
|
||||
authorId: "agent",
|
||||
authorType: "agent",
|
||||
createdAt: "2026-07-16T12:00:00.000Z",
|
||||
updatedAt: "2026-07-16T12:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
function HistoryHarness({ children, onReady }: { children: ReactNode; onReady: (history: UseNavigationHistoryResult) => void }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
useEffect(() => onReady(history), [history, onReady]);
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
function dispatchPopState(navIndex: number) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex } }));
|
||||
});
|
||||
}
|
||||
|
||||
const galleryProps = {
|
||||
artifacts,
|
||||
isMobile: true,
|
||||
addToast: vi.fn(),
|
||||
onOpenTask: vi.fn(),
|
||||
};
|
||||
|
||||
function openImageViewer() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
|
||||
}
|
||||
|
||||
function openDocumentViewer() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Document artifact" }));
|
||||
}
|
||||
|
||||
function expectViewerOpen(container: HTMLElement) {
|
||||
expect(container.querySelector(".artifacts-gallery-viewer")).not.toBeNull();
|
||||
}
|
||||
|
||||
describe("ArtifactsGallery mobile viewer navigation history", () => {
|
||||
let navigationHistory: UseNavigationHistoryResult | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
navigationHistory = null;
|
||||
window.history.replaceState({ navIndex: 0 }, "");
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWithHistory(isMobile = true) {
|
||||
return render(
|
||||
<HistoryHarness onReady={(history) => { navigationHistory = history; }}>
|
||||
<ArtifactsGallery {...galleryProps} isMobile={isMobile} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
}
|
||||
|
||||
it("dismisses the media viewer on browser Back and drains its nav entry", async () => {
|
||||
const { container } = renderWithHistory();
|
||||
openImageViewer();
|
||||
expectViewerOpen(container);
|
||||
|
||||
dispatchPopState(0);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Expand Image artifact" })).toBeInTheDocument();
|
||||
dispatchPopState(0);
|
||||
expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Android native Back through popstate before dismissing the document viewer", async () => {
|
||||
const { container } = renderWithHistory();
|
||||
openDocumentViewer();
|
||||
expectViewerOpen(container);
|
||||
|
||||
const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true });
|
||||
expect(window.dispatchEvent(nativeBack)).toBe(false);
|
||||
expect(window.history.back).toHaveBeenCalledOnce();
|
||||
expectViewerOpen(container);
|
||||
|
||||
dispatchPopState(0);
|
||||
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Open Document artifact" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
async function expectProgrammaticCloseConsumesViewerEntry(close: () => void) {
|
||||
const { container } = renderWithHistory();
|
||||
await waitFor(() => expect(navigationHistory).not.toBeNull());
|
||||
const sentinelClose = vi.fn();
|
||||
navigationHistory?.pushNav({ type: "modal", close: sentinelClose });
|
||||
openImageViewer();
|
||||
expectViewerOpen(container);
|
||||
|
||||
close();
|
||||
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
|
||||
expect(window.history.back).toHaveBeenCalledOnce();
|
||||
|
||||
// The first pop is removeNav's self-pop; exactly one more pop reaches the lower entry.
|
||||
dispatchPopState(1);
|
||||
expect(sentinelClose).not.toHaveBeenCalled();
|
||||
dispatchPopState(0);
|
||||
expect(sentinelClose).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
it("consumes the viewer entry when the header close button dismisses it", async () => {
|
||||
await expectProgrammaticCloseConsumesViewerEntry(() => fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" })));
|
||||
});
|
||||
|
||||
it("consumes the viewer entry when Escape dismisses it", async () => {
|
||||
await expectProgrammaticCloseConsumesViewerEntry(() => fireEvent.keyDown(document, { key: "Escape" }));
|
||||
});
|
||||
|
||||
it("keeps provider-less gallery renders functional", async () => {
|
||||
const { container } = render(<ArtifactsGallery {...galleryProps} />);
|
||||
openImageViewer();
|
||||
expectViewerOpen(container);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close artifact preview" }));
|
||||
await waitFor(() => expect(container.querySelector(".artifacts-gallery-viewer")).toBeNull());
|
||||
});
|
||||
|
||||
it("does not push a viewer entry on desktop", async () => {
|
||||
renderWithHistory(false);
|
||||
await waitFor(() => expect(navigationHistory).not.toBeNull());
|
||||
const sentinelClose = vi.fn();
|
||||
navigationHistory?.pushNav({ type: "modal", close: sentinelClose });
|
||||
openImageViewer();
|
||||
|
||||
dispatchPopState(0);
|
||||
expect(sentinelClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user