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%;
|
width: 100%;
|
||||||
height: 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 === */
|
/* === Settings Layout === */
|
||||||
|
|||||||
@@ -3612,6 +3612,23 @@ export function SettingsModal({
|
|||||||
×
|
×
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
|
<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