FN-7627: add mobile close button to embedded Settings screen
Adds a close affordance to the embedded Settings header on mobile, since only a bottom nav bar (no sidebar) is available to exit there. - Render a mobile-only `modal-close` button in the embedded Settings header when `isEmbedded && viewportMode === "mobile"`, wired to the existing `onClose` prop. - Leave desktop/tablet embedded and the standalone modal presentation unchanged. - Add regression tests covering the new mobile close button. - Add a patch changeset documenting the fix. Files changed: .changeset/fn-7627-mobile-settings-close.md | 7 ++ .../dashboard/app/components/SettingsModal.css | 16 +++ .../dashboard/app/components/SettingsModal.tsx | 17 +++ .../__tests__/SettingsModal.mobileClose.test.tsx | 125 +++++++++++++++++++++ 4 files changed, 165 insertions(+) Fusion-Task-Id: FN-7627 Fusion-Task-Lineage: b39c7172-bce3-4fb2-9095-58ae376c43ef Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7627-mobile-settings-close.md
Normal file
7
.changeset/fn-7627-mobile-settings-close.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Add a close button to the Settings screen on mobile.
|
||||
category: fix
|
||||
dev: The embedded Settings header now renders a mobile-only `modal-close` control gated on `isEmbedded && viewportMode === "mobile"`, wired to the existing `onClose` prop (navigates back to the board and refreshes app settings). Desktop/tablet embedded and the standalone modal presentation are unchanged.
|
||||
@@ -235,6 +235,22 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Settings 2026-07-07-00:00:
|
||||
Mobile embedded Settings has no left sidebar to exit through (only the bottom MobileNavBar), so the header needs
|
||||
an explicit close affordance. Reuse the shared .modal-close sizing/hover and pin it to the header's trailing
|
||||
edge, after the Star/Discord actions, without displacing them. Desktop/tablet embedded and the standalone modal
|
||||
presentation are untouched by this rule (scoped to .settings-modal--embedded .modal-header--embedded).
|
||||
*/
|
||||
.settings-modal--embedded .modal-header--embedded .settings-embedded-mobile-close {
|
||||
--settings-embedded-mobile-close-touch-target: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
|
||||
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-xs);
|
||||
min-height: var(--settings-embedded-mobile-close-touch-target);
|
||||
min-width: var(--settings-embedded-mobile-close-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Settings Layout === */
|
||||
|
||||
@@ -3612,6 +3612,23 @@ export function SettingsModal({
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
FNXC:Settings 2026-07-07-00:00:
|
||||
Mobile embedded Settings (taskView === "settings", presentation="embedded") has no left sidebar to exit
|
||||
through — only the bottom MobileNavBar — so the header needs an explicit close affordance calling the
|
||||
existing onClose prop (wired to closeSettingsView: modalManager.closeSettings() + back to board + refresh
|
||||
app settings). Desktop/tablet embedded still exit via the sidebar (no button here), and the standalone
|
||||
modal presentation keeps its own `!isEmbedded` `modal-close` button above, untouched and byte-identical.
|
||||
*/}
|
||||
{isEmbedded && viewportMode === "mobile" && (
|
||||
<button
|
||||
className="modal-close settings-embedded-mobile-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("actions.close", "Close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
|
||||
/*
|
||||
FNXC:Settings 2026-07-07-00:00:
|
||||
FN-7627 Surface Enumeration coverage: mobile embedded Settings has no left sidebar to exit through, so the
|
||||
embedded header renders a mobile-only close button (isEmbedded && viewportMode === "mobile") calling onClose.
|
||||
This suite asserts the invariant across every enumerated surface: embedded+mobile (renders, calls onClose, works
|
||||
with/without a selected projectId), embedded+desktop (no button), embedded+tablet (no button), and the standalone
|
||||
modal presentation (its existing `!isEmbedded` modal-close `×` stays the only close control, not duplicated).
|
||||
*/
|
||||
|
||||
const mockFetchSettings = vi.fn();
|
||||
const mockFetchSettingsByScope = vi.fn();
|
||||
const mockFetchAuthStatus = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockFetchCustomProviders = vi.fn();
|
||||
const mockFetchMemoryFiles = vi.fn();
|
||||
const mockFetchGlobalConcurrency = vi.fn();
|
||||
const mockFetchDashboardHealth = vi.fn();
|
||||
const mockUseViewportMode = vi.fn();
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
|
||||
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
|
||||
getViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
|
||||
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||
}));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: () => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 }),
|
||||
}));
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
|
||||
|
||||
const defaultSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
autoMerge: true,
|
||||
worktrunk: { enabled: false, binaryPath: "", onFailure: "fail" },
|
||||
};
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof SettingsModal>> = {}) {
|
||||
return render(<SettingsModal onClose={() => {}} addToast={() => {}} initialSection="general" {...props} />);
|
||||
}
|
||||
|
||||
describe("SettingsModal mobile embedded close button (FN-7627)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSettings.mockResolvedValue(defaultSettings);
|
||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
|
||||
mockFetchMemoryFiles.mockResolvedValue({ files: [] });
|
||||
mockFetchGlobalConcurrency.mockResolvedValue({ maxConcurrentRuns: 4 });
|
||||
mockFetchDashboardHealth.mockResolvedValue({});
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
});
|
||||
|
||||
it("renders a close button in embedded+mobile with an accessible name and calls onClose exactly once", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const onClose = vi.fn();
|
||||
renderModal({ presentation: "embedded", projectId: "proj-1", onClose });
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
fireEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders no header close button in embedded+desktop", async () => {
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
renderModal({ presentation: "embedded", projectId: "proj-1" });
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no header close button in embedded+tablet (no leftover/duplicate affordance)", async () => {
|
||||
mockUseViewportMode.mockReturnValue("tablet");
|
||||
renderModal({ presentation: "embedded", projectId: "proj-1" });
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders exactly one modal-close button in the standalone modal presentation and does not add the mobile-embedded control", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const { container } = renderModal({ presentation: "modal" });
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
const closeButtons = screen.getAllByRole("button", { name: "Close" });
|
||||
expect(closeButtons).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".modal-close")).toHaveLength(1);
|
||||
expect(container.querySelector(".settings-embedded-mobile-close")).toBeNull();
|
||||
});
|
||||
|
||||
it("still renders and calls onClose in embedded+mobile when opened without a selected projectId (overview entry)", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const onClose = vi.fn();
|
||||
renderModal({ presentation: "embedded", projectId: undefined, onClose });
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
fireEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user