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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 14:13:41 -07:00
parent eae8d9dabe
commit a746a02f3b
3 changed files with 107 additions and 6 deletions

View File

@@ -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.
<!-- FNXC:SettingsDocs 2026-07-11-18:58: FN-7825 makes the Settings navigation rail read as one clean surface: no vertical content divider, single-line section rows, and a desktop/tablet resize handle with browser-local width persistence. Mobile remains the stacked section picker. -->
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**.
<!-- FNXC:SettingsDefaults 2026-07-04-00:00: FN-7505 requires every user-editable setting's help text to state its own default value, so operators reading a field's description know what it defaults to without checking the reference doc. -->
Every user-editable setting's help text (the `.settings-description`/`<small>` 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.

View File

@@ -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 (
<option key={section.id} value={section.id}>
{resolveSettingsSectionOptionLabel(section, label)}
</option>
<optgroup key={groupHeader.id} label={t(groupHeader.labelKey, groupHeader.label)}>
{groupSections.map((section) => {
const label = t(section.labelKey, section.label);
return (
<option key={section.id} value={section.id}>
{resolveSettingsSectionOptionLabel(section, label)}
</option>
);
})}
</optgroup>
);
})}
</select>

View File

@@ -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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(container.querySelector(".settings-sidebar")).toBeTruthy();
expect(container.querySelector(".settings-mobile-section-picker")).toBeNull();
});
});
});