From a746a02f3b0500185434b830177ea5f2ef8e6e34 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 14:13:41 -0700 Subject: [PATCH] FN-8236: group mobile Settings sections by topic Group the mobile Settings picker by desktop navigation topics while preserving scoped section adjacency. - Render mobile section choices in native topic optgroups - Preserve Global-before-Project ordering and search-aware empty groups - Cover grouped picker behavior and document the mobile navigation Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/SettingsModal.tsx | 31 +++++++-- packages/dashboard/app/components/__tests__/settings-mobile.test.tsx | 80 ++++++++++++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-8236 Fusion-Task-Lineage: 3a90bc72-2393-4b41-a7c3-2c9e30c94e01 Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 2 +- .../app/components/SettingsModal.tsx | 31 +++++-- .../__tests__/settings-mobile.test.tsx | 80 +++++++++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 825568fc81..bb12624fdd 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -17,7 +17,7 @@ Use **Search settings** at the top of Settings to find the section that contains On mobile, the search row starts collapsed behind a compact toggle icon beside the **Settings Section** dropdown to save vertical space; tap it to reveal the search input and tap again to hide it. An in-progress search query is preserved across collapse/expand. Desktop and tablet always show the search row with no toggle. -On desktop and tablet, the Settings navigation rail has no hard divider between navigation and content; section rows stay on one line and use ellipsis for unusually long labels. Drag the thin handle between the navigation rail and content pane to widen or narrow the rail. Fusion remembers that width in browser storage and restores it for both the standalone Settings modal and embedded Settings page. Mobile keeps the stacked **Settings Section** picker and does not show the resize handle. +On desktop and tablet, the Settings navigation rail has no hard divider between navigation and content; section rows stay on one line and use ellipsis for unusually long labels. Drag the thin handle between the navigation rail and content pane to widen or narrow the rail. Fusion remembers that width in browser storage and restores it for both the standalone Settings modal and embedded Settings page. Mobile keeps the stacked **Settings Section** picker and does not show the resize handle. Its native menu groups sections by the same topic headers as the desktop rail; paired scoped entries keep **Global** immediately before **Project**. Every user-editable setting's help text (the `.settings-description`/`` hint under a field) states its own default value — for example “Default: 3.”, “Default: enabled.”, or “No default — unset (inherits the global setting).” for values that fall back to another scope. Canonical default values come from `DEFAULT_GLOBAL_SETTINGS` / `DEFAULT_PROJECT_SETTINGS` in `packages/core/src/settings-schema.ts`; the dashboard copy never invents a number. A guard test (`settings-default-descriptions.test.tsx`) enforces that every surfaced setting states its default and that every `DEFAULT_SETTINGS` key is either documented or explicitly allowlisted as not surfaced in the Settings UI. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index b401b60ab0..fc3fd3cd2b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -4333,12 +4333,33 @@ export function SettingsModal({ value={activeSection} onChange={(event) => setActiveSection(event.target.value as SectionId)} > - {searchableSectionOptions.map((section) => { - const label = t(section.labelKey, section.label); + {/* + FNXC:SettingsNavigation 2026-07-16-14:00: + FN-8236 makes the mobile picker mirror the desktop topic headers with native + optgroups. Sections retain SETTINGS_SECTIONS order within each group, which + keeps scoped Global entries immediately before their Project counterparts so + mobile operators can relate inherited settings without a separate sidebar. + */} + {searchMatchedSections.map((groupHeader, groupIndex) => { + if (!groupHeader.isGroupHeader) return null; + + const sections = searchMatchedSections.slice(groupIndex + 1).filter((section) => !section.isGroupHeader); + const nextGroupIndex = searchMatchedSections.slice(groupIndex + 1).findIndex((section) => section.isGroupHeader); + const groupSections = nextGroupIndex === -1 ? sections : sections.slice(0, nextGroupIndex); + + if (groupSections.length === 0) return null; + return ( - + + {groupSections.map((section) => { + const label = t(section.labelKey, section.label); + return ( + + ); + })} + ); })} diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index f197d10c2a..ab14ac6df4 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -971,4 +971,84 @@ describe("SettingsModal mobile adaptations", () => { expectBaseRule(css, ".settings-search-toggle", "display: none;"); }); }); + + describe("mobile section picker groups (FN-8236)", () => { + it("mirrors desktop groups, omits advanced-only entries until enabled, and keeps scoped pairs global first", async () => { + mockSettingsViewport(true); + localStorage.setItem("fusion:settings:show-advanced", "false"); + const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + const { getByLabelText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + const select = getByLabelText("Settings Section") as HTMLSelectElement; + const groupLabels = Array.from(select.querySelectorAll("optgroup")).map((group) => group.label); + expect(groupLabels).toEqual(["Preferences", "Project", "AI & Models", "Automation", "Integrations", "Infrastructure"]); + expect(Array.from(select.querySelectorAll("optgroup")).every((group) => group.querySelectorAll("option").length > 0)).toBe(true); + expect(Array.from(select.options).map((option) => option.text)).not.toContain("MCP Servers · Global"); + + const scopedPairs = [ + ["global-models", "project-models"], + ["research-global", "research-project"], + ["scheduling-global", "scheduling"], + ["source-control-global", "source-control"], + ["backups-global", "backups"], + ]; + const optionIds = Array.from(select.options).map((option) => option.value); + const visibleScopedPairs = scopedPairs.filter(([globalId, projectId]) => optionIds.includes(globalId) && optionIds.includes(projectId)); + expect(visibleScopedPairs).toEqual(expect.arrayContaining([ + ["global-models", "project-models"], + ["scheduling-global", "scheduling"], + ["source-control-global", "source-control"], + ])); + for (const [globalId, projectId] of visibleScopedPairs) { + expect(optionIds.indexOf(globalId)).toBe(optionIds.indexOf(projectId) - 1); + } + + await user.click(getByLabelText("Advanced settings")); + await waitFor(() => expect(Array.from(select.options).map((option) => option.text)).toContain("MCP Servers · Global")); + const advancedGroups = Array.from(select.querySelectorAll("optgroup")).map((group) => group.label); + expect(advancedGroups).toContain("Advanced"); + + const advancedOptionIds = Array.from(select.options).map((option) => option.value); + for (const [globalId, projectId] of [ + ["global-mcp", "mcp"], + ["research-global", "research-project"], + ]) { + const globalIndex = advancedOptionIds.indexOf(globalId); + const projectIndex = advancedOptionIds.indexOf(projectId); + if (globalIndex >= 0 && projectIndex >= 0) { + expect(globalIndex).toBe(projectIndex - 1); + } + } + }); + + it("keeps only matching non-empty groups during search and replaces the picker with the empty hint for no matches", async () => { + mockSettingsViewport(true); + const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + const { container, getByLabelText, getByTestId, findByText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + await user.click(getByLabelText("Show search")); + await user.type(getByTestId("settings-search-input"), "mcp"); + + const select = getByLabelText("Settings Section") as HTMLSelectElement; + expect(Array.from(select.querySelectorAll("optgroup")).map((group) => group.label)).toEqual(["Integrations"]); + expect(Array.from(select.options).map((option) => option.text)).toEqual(["MCP Servers · Global", "MCP Servers · Project"]); + + await user.clear(getByTestId("settings-search-input")); + await user.type(getByTestId("settings-search-input"), "zzzzzz-no-match"); + await findByText("No sections match this search."); + expect(container.querySelector("#settings-mobile-section")).toBeNull(); + expect(container.querySelector(".settings-mobile-section-picker optgroup")).toBeNull(); + }); + + it("leaves desktop navigation as the sidebar without a mobile picker", async () => { + mockSettingsViewport(false); + const { container } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + expect(container.querySelector(".settings-sidebar")).toBeTruthy(); + expect(container.querySelector(".settings-mobile-section-picker")).toBeNull(); + }); + }); });