From ab5ec323dff301b25b6f14e9849aa4e5451ebf34 Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Mon, 23 Mar 2026 03:54:41 +0000 Subject: [PATCH] Fix #2568: move settings section titles to header Settings section titles were previously rendered inside the scrollable content area, causing them to scroll out of view and breaking the intended visual hierarchy. This change ensures that section titles are rendered in the settings header alongside the navigation controls, preserving visibility and layout consistency across all settings tabs. A regression test was added to verify that tab titles are rendered in the header area and do not appear inside the scrollable content region, preventing future regressions. --- CHANGELOG.md | 1 + .../Settings/SettingsComponent.test.tsx | 245 ++++++++++++++++++ .../components/Settings/SettingsComponent.tsx | 62 +++-- .../Tabs/Attachments/AttachmentsComponent.tsx | 5 - .../Settings/Tabs/Invite/Invite.component.tsx | 7 - .../Notifications/NotificationsComponent.tsx | 5 - .../Settings/Tabs/QRCode/QRCode.component.tsx | 34 +-- .../debugInfo/debugInfoComponent.tsx | 6 - .../components/widgets/Settings/About.tsx | 17 +- 9 files changed, 305 insertions(+), 77 deletions(-) create mode 100644 packages/desktop/src/renderer/components/Settings/SettingsComponent.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c79775009f..53981a48fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes * Fix: leaving a community now purges uploaded and downloaded files; if the leave is interrupted (process killed, OS-terminated, power loss), the purge is finished on the next app launch [#3225](https://github.com/TryQuiet/quiet/issues/3225) +* Fixed settings section titles rendering inside the scrollable content; moved section titles to the settings header and added a regression test to prevent future regressions [#2568](https://github.com/TryQuiet/quiet/issues/2568) ## [7.2.0] diff --git a/packages/desktop/src/renderer/components/Settings/SettingsComponent.test.tsx b/packages/desktop/src/renderer/components/Settings/SettingsComponent.test.tsx new file mode 100644 index 0000000000..0f30da8042 --- /dev/null +++ b/packages/desktop/src/renderer/components/Settings/SettingsComponent.test.tsx @@ -0,0 +1,245 @@ +import React from 'react' +import { render, screen, fireEvent, within, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import { SettingsComponent } from './SettingsComponent' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const noOp = jest.fn() + +const mockLeaveCommunityModal = { + open: false, + handleOpen: jest.fn(), + handleClose: jest.fn(), +} + +const MockTab: React.FC<{ handleClose: () => void }> = ({ handleClose }) => ( +
+

Tab content

+ +
+) + +const defaultTabs: Record void }>> = { + about: MockTab, + notifications: MockTab, + attachments: MockTab, + invite: MockTab, + qrcode: MockTab, + leaveCommunity: MockTab, + debug: MockTab, +} + +function renderSettings(overrides: Partial> = {}) { + return render( + + ) +} + +// Click a menu item and wait for the tab drawer to fully mount. +// MUI Drawer only inserts children into the DOM when open=true, so we must +// wait after the state-updating click before querying the second drawer. +async function openTab(testId: string) { + fireEvent.click(screen.getByTestId(testId)) + return screen.findByTestId('ArrowBackIcon') // resolves once drawer is mounted +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SettingsComponent', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ── Rendering ───────────────────────────────────────────────────────────── + + describe('main drawer', () => { + it('renders the "Community Settings" heading when open', () => { + renderSettings() + expect(screen.getByText('Community Settings')).toBeInTheDocument() + }) + + it('renders all expected menu items', () => { + renderSettings() + expect(screen.getByTestId('about-settings-tab')).toBeInTheDocument() + expect(screen.getByTestId('notifications-settings-tab')).toBeInTheDocument() + expect(screen.getByTestId('attachments-settings-tab')).toBeInTheDocument() + expect(screen.getByTestId('invite-settings-tab')).toBeInTheDocument() + expect(screen.getByTestId('qr-code-settings-tab')).toBeInTheDocument() + }) + + it('calls handleClose when the close button is clicked', () => { + const handleClose = jest.fn() + renderSettings({ handleClose }) + fireEvent.click(screen.getByTestId('CloseIcon')) + expect(handleClose).toHaveBeenCalledTimes(1) + }) + }) + + // ── Tab drawer – issue #2568 regression ─────────────────────────────────── + + describe('issue #2568 – tab title in top bar, not in content area', () => { + /** + * When a tab is opened the title must appear inside the header ListItem + * (the element that also contains the back-arrow button), NOT inside the + * scrollable content area below the divider. + * + * Both drawers stay in the DOM simultaneously once the tab is open, so + * some title strings (e.g. "Notifications") appear twice – once as a menu + * item in drawer 1 and once as the header title in drawer 2. We therefore + * scope every assertion to the specific container. + */ + it.each([ + ['about', 'About Quiet'], + ['notifications', 'Notifications'], + ['attachments', 'Files and Images'], + ['invite', 'Add Members'], + ['qr-code', 'QR Code'], + ])('"%s" tab shows "%s" in the header bar', async (testId, expectedTitle) => { + renderSettings() + + // openTab uses fireEvent + findByTestId (async) to wait for the second + // drawer to mount – fireEvent reliably triggers MUI's onClick handlers. + const backButton = await openTab(`${testId}-settings-tab`) + + // The title must be inside the same ListItem as the back button (= the header bar) + const headerListItem = backButton.closest('li') as HTMLElement + expect(headerListItem).not.toBeNull() + expect(within(headerListItem).getByText(expectedTitle)).toBeInTheDocument() + + // The scrollable content area must NOT contain the title + const tabContent = screen.getByTestId('mock-tab-content') + expect(within(tabContent).queryByText(expectedTitle)).not.toBeInTheDocument() + }) + }) + + // ── Tab navigation ──────────────────────────────────────────────────────── + + describe('tab navigation', () => { + it('opens the tab drawer when a menu item is clicked', async () => { + renderSettings() + expect(screen.queryByTestId('close-tab-button-box')).not.toBeInTheDocument() + + await openTab('notifications-settings-tab') + + expect(screen.getByTestId('ArrowBackIcon')).toBeInTheDocument() + }) + + it('renders the tab component content after opening a tab', async () => { + renderSettings() + await openTab('about-settings-tab') + + expect(screen.getByTestId('mock-tab-content')).toBeInTheDocument() + }) + + it('closes the tab drawer when the back button is clicked', async () => { + renderSettings() + const backButton = await openTab('notifications-settings-tab') + + fireEvent.click(backButton) + + await waitFor(() => { + expect(screen.queryByTestId('close-tab-button-box')).not.toBeInTheDocument() + }) + }) + + it('closes the tab drawer when the tab itself calls handleClose', async () => { + renderSettings() + await openTab('notifications-settings-tab') + + fireEvent.click(screen.getByText('Close from tab')) + + await waitFor(() => { + expect(screen.queryByTestId('mock-tab-content')).not.toBeInTheDocument() + }) + }) + + it('shows the correct title when switching between tabs', async () => { + renderSettings() + + // Open notifications and verify its title is in the header + const backButton1 = await openTab('notifications-settings-tab') + const header1 = backButton1.closest('li') as HTMLElement + expect(within(header1).getByText('Notifications')).toBeInTheDocument() + + // Go back to the main menu + fireEvent.click(backButton1) + await waitFor(() => { + expect(screen.queryByTestId('close-tab-button-box')).not.toBeInTheDocument() + }) + + // Open attachments and verify its title + const backButton2 = await openTab('attachments-settings-tab') + const header2 = backButton2.closest('li') as HTMLElement + expect(within(header2).getByText('Files and Images')).toBeInTheDocument() + }) + }) + + // ── isWindows prop ──────────────────────────────────────────────────────── + + describe('isWindows prop', () => { + it('hides the "Leave community" item on Windows', () => { + renderSettings({ isWindows: true }) + expect(screen.queryByTestId('leave-community-settings-tab')).not.toBeInTheDocument() + }) + + it('shows the "Leave community" item on non-Windows platforms', () => { + renderSettings({ isWindows: false }) + expect(screen.getByTestId('leave-community-settings-tab')).toBeInTheDocument() + }) + + it('shows the "Leave community" item when isWindows is undefined', () => { + renderSettings({ isWindows: undefined }) + expect(screen.getByTestId('leave-community-settings-tab')).toBeInTheDocument() + }) + }) + + // ── Debug tab (environment-gated) ───────────────────────────────────────── + + describe('debug tab', () => { + const originalEnv = process.env + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('shows the debug tab in development mode', () => { + process.env = { ...originalEnv, NODE_ENV: 'development' } + renderSettings() + expect(screen.getByTestId('debug-settings-tab')).toBeInTheDocument() + }) + + it('shows the debug tab when IS_E2E is "true"', () => { + process.env = { ...originalEnv, NODE_ENV: 'production', IS_E2E: 'true' } + renderSettings() + expect(screen.getByTestId('debug-settings-tab')).toBeInTheDocument() + }) + + it('hides the debug tab in production without IS_E2E', () => { + process.env = { ...originalEnv, NODE_ENV: 'production', IS_E2E: undefined } + renderSettings() + expect(screen.queryByTestId('debug-settings-tab')).not.toBeInTheDocument() + }) + }) + + // ── Closed state ────────────────────────────────────────────────────────── + + describe('closed state', () => { + it('does not mount main drawer content when open is false', () => { + // MUI Drawer unmounts its children when open=false (no keepMounted), + // so queryByText returns null rather than a hidden element. + renderSettings({ open: false }) + expect(screen.getByText('Community Settings')).not.toBeVisible() + }) + }) +}) diff --git a/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx b/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx index b23486e960..6483964203 100644 --- a/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx +++ b/packages/desktop/src/renderer/components/Settings/SettingsComponent.tsx @@ -14,6 +14,16 @@ const classes = { leaveComunity: `${PREFIX}leaveCommunity`, } +const TAB_TITLES: Record = { + about: 'About Quiet', + notifications: 'Notifications', + attachments: 'Files and Images', + invite: 'Add Members', + qrcode: 'QR Code', + leaveCommunity: 'Leave community', + debug: 'Debug Information', +} + export interface SettingsComponentProps { open: boolean handleClose: () => void @@ -47,16 +57,12 @@ export const SettingsComponent: React.FC = ({ return ( <> - - - -
- - - - - -
+ + + + + + Community Settings @@ -102,7 +108,7 @@ export const SettingsComponent: React.FC = ({ className={classes.leaveComunity} onClick={() => handleChange('leaveCommunity')} > - Leave community + Leave community @@ -118,17 +124,29 @@ export const SettingsComponent: React.FC = ({ )} - - - {currentTab !== '' ? : } - - - - {TabComponent && } + theme.zIndex.modal + 1 }} + > + + + + + + + + {TAB_TITLES[currentTab]} + + + + + + {TabComponent && } + diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Attachments/AttachmentsComponent.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Attachments/AttachmentsComponent.tsx index 15855d883a..f693acad5a 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/Attachments/AttachmentsComponent.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/Attachments/AttachmentsComponent.tsx @@ -154,11 +154,6 @@ export const AttachmentsComponent: React.FC = ({ maxAutodownlo return ( - - - Files and Images - - Auto-download... diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.tsx index 0b654a46e5..6b09aa2a24 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.tsx @@ -99,13 +99,6 @@ export const InviteComponent: FC = ({ } return ( - - - - Add Members - - - Your community link diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Notifications/NotificationsComponent.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Notifications/NotificationsComponent.tsx index ef8130bfa8..85c9182798 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/Notifications/NotificationsComponent.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/Notifications/NotificationsComponent.tsx @@ -121,11 +121,6 @@ export const NotificationsComponent: React.FC = ({ }) => { return ( - - - Notifications - - Notify me about... diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.component.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.component.tsx index ba6c1a22df..f08b990324 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.component.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.component.tsx @@ -34,10 +34,7 @@ export const QRCodeComponent: FC = ({ value }) => { return ( - Only admins can invite new members - - - + Only admins can invite new members to this community. Ask the community creator for a QR code to share. @@ -45,22 +42,19 @@ export const QRCodeComponent: FC = ({ value }) => { ) } return ( - - - - - - - - Invitation QR code - - - - This community QR code is private. If it is shared with someone, they can scan it with their camera to - join this community. - - - + + + + + + + This community QR code is private. If it is shared with someone, they can scan it with their camera to join + this community. + ) diff --git a/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx b/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx index cf52edc997..be0262fc88 100644 --- a/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx +++ b/packages/desktop/src/renderer/components/debugInfo/debugInfoComponent.tsx @@ -204,12 +204,6 @@ export const DebugInfoComponent: React.FC = () => { return ( - - - Debug Information - - - Summary diff --git a/packages/desktop/src/renderer/components/widgets/Settings/About.tsx b/packages/desktop/src/renderer/components/widgets/Settings/About.tsx index 6be554d6f3..b5d3d45e8e 100644 --- a/packages/desktop/src/renderer/components/widgets/Settings/About.tsx +++ b/packages/desktop/src/renderer/components/widgets/Settings/About.tsx @@ -24,19 +24,12 @@ export const About: FC = () => { const version = app.getVersion() return ( - - - About Quiet - - - - - Version: {version} -
- Copyright © {new Date().getFullYear()} Quiet LLC and other contributors -
-
+ + Version: {version} +
+ Copyright © {new Date().getFullYear()} Quiet LLC and other contributors +
)