diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 66a6b526b..97b4f507d 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCoreState } from '../../providers/CoreStateProvider'; @@ -15,6 +15,29 @@ import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; import { useSettingsNavigation } from './hooks/useSettingsNavigation'; +interface SettingsSection { + label: string; + items: SettingsItem[]; +} + +interface SettingsItem { + id: string; + title: string; + description: string; + icon: ReactNode; + onClick: () => void; + dangerous?: boolean; +} + +// Subtle uppercase section header label separating settings groups +const SectionHeader = ({ label }: { label: string }) => ( +
+ + {label} + +
+); + const SettingsHome = () => { const navigate = useNavigate(); const { navigateToSettings } = useSettingsNavigation(); @@ -93,157 +116,185 @@ const SettingsHome = () => { } }; - // const handleViewEncryptionKey = () => { - // // TODO: Show encryption key in a secure modal - // console.log('View encryption key'); - // }; - - const groupedMenuItems = [ + const settingsSections: SettingsSection[] = [ { - id: 'account', - title: 'Account', - description: 'Recovery phrase, team, connections, and privacy', - icon: ( - - - - ), - onClick: () => navigateToSettings('account'), - dangerous: false, + label: 'General', + items: [ + { + id: 'account', + title: 'Account', + description: 'Recovery phrase, team, connections, and privacy', + icon: ( + + + + ), + onClick: () => navigateToSettings('account'), + }, + { + id: 'notifications', + title: 'Notifications', + description: 'Do Not Disturb and per-account notification controls', + icon: ( + + + + ), + onClick: () => navigateToSettings('notifications'), + }, + ], }, { - id: 'billing', - title: 'Billing & Usage', - description: 'Subscription plan, credits, and payment methods', - icon: ( - - - - ), - onClick: () => { - void openUrl(BILLING_DASHBOARD_URL); - }, - dangerous: false, + label: 'Features & AI', + items: [ + { + id: 'features', + title: 'Features', + description: 'Screen awareness, messaging, and tools', + icon: ( + + + + ), + onClick: () => navigateToSettings('features'), + }, + { + id: 'ai-models', + title: 'AI & Models', + description: 'Local AI model setup and downloads', + icon: ( + + + + ), + onClick: () => navigateToSettings('ai-models'), + }, + ], }, { - id: 'features', - title: 'Features', - description: 'Screen awareness, messaging, and tools', - icon: ( - - - - ), - onClick: () => navigateToSettings('features'), - dangerous: false, + label: 'Billing & Rewards', + items: [ + { + id: 'billing', + title: 'Billing & Usage', + description: 'Subscription plan, credits, and payment methods', + icon: ( + + + + ), + onClick: () => { + void openUrl(BILLING_DASHBOARD_URL); + }, + }, + { + id: 'rewards', + title: 'Rewards', + description: 'Referrals, coupons, and earned credits', + icon: ( + + + + ), + onClick: () => navigate('/rewards'), + }, + ], }, { - id: 'ai-models', - title: 'AI & Models', - description: 'Local AI model setup and downloads', - icon: ( - - - - ), - onClick: () => navigateToSettings('ai-models'), - dangerous: false, + label: 'Support', + items: [ + { + id: 'restart-tour', + title: 'Restart Tour', + description: 'Replay the product walkthrough from the beginning', + icon: ( + + + + ), + onClick: () => { + resetWalkthrough(); + navigate('/home'); + }, + }, + { + id: 'about', + title: 'About', + description: 'App version and software updates', + icon: ( + + + + ), + onClick: () => navigateToSettings('about'), + }, + ], }, { - id: 'notifications', - title: 'Notifications', - description: 'Do Not Disturb and per-account notification controls', - icon: ( - - - - ), - onClick: () => navigateToSettings('notifications'), - dangerous: false, - }, - { - id: 'restart-tour', - title: 'Restart Tour', - description: 'Replay the product walkthrough from the beginning', - icon: ( - - - - ), - onClick: () => { - resetWalkthrough(); - navigate('/home'); - }, - dangerous: false, - }, - { - id: 'about', - title: 'About', - description: 'App version and software updates', - icon: ( - - - - ), - onClick: () => navigateToSettings('about'), - dangerous: false, - }, - { - id: 'developer-options', - title: 'Developer Options', - description: 'Diagnostics, debug panels, webhooks, and memory inspection', - icon: ( - - - - ), - onClick: () => navigateToSettings('developer-options'), - dangerous: false, + label: 'Advanced', + items: [ + { + id: 'developer-options', + title: 'Developer Options', + description: 'Diagnostics, debug panels, webhooks, and memory inspection', + icon: ( + + + + ), + onClick: () => navigateToSettings('developer-options'), + }, + ], }, ]; - // Destructive actions menu items - const destructiveMenuItems = [ + // Destructive actions — rendered separately under "Danger Zone" heading + const destructiveItems: SettingsItem[] = [ { id: 'logout-and-clear', title: 'Clear App Data', @@ -287,22 +338,28 @@ const SettingsHome = () => {
- {/* Grouped Settings */} - {groupedMenuItems.map((item, index) => ( - + {/* Grouped sections with section headers */} + {settingsSections.map(section => ( +
+ + {section.items.map((item, index) => ( + + ))} +
))} - {/* Destructive Actions */} - {destructiveMenuItems.map((item, index) => ( + {/* Danger Zone */} + + {destructiveItems.map((item, index) => ( { onClick={item.onClick} dangerous={item.dangerous} isFirst={index === 0} - isLast={index === destructiveMenuItems.length - 1} + isLast={index === destructiveItems.length - 1} /> ))}
diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx new file mode 100644 index 000000000..560e57203 --- /dev/null +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -0,0 +1,248 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SettingsHome from '../SettingsHome'; + +// --- hoisted mocks --- + +const { mockNavigate, mockNavigateToSettings } = vi.hoisted(() => ({ + mockNavigate: vi.fn(), + mockNavigateToSettings: vi.fn(), +})); + +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }), +})); + +vi.mock('../../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ + clearSession: vi.fn().mockResolvedValue(undefined), + snapshot: { auth: { userId: null }, currentUser: null }, + }), +})); + +vi.mock('../../../store', () => ({ persistor: { purge: vi.fn().mockResolvedValue(undefined) } })); + +vi.mock('../../../utils/links', () => ({ BILLING_DASHBOARD_URL: 'https://billing.example.com' })); + +vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); + +vi.mock('../../../utils/tauriCommands', () => ({ + resetOpenHumanDataAndRestartCore: vi.fn().mockResolvedValue(undefined), + restartApp: vi.fn().mockResolvedValue(undefined), + scheduleCefProfilePurge: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() })); + +// --- helpers --- + +function renderSettingsHome() { + return render( + + + + ); +} + +// --- tests --- + +describe('SettingsHome', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('section headers', () => { + it('renders the General section header', () => { + renderSettingsHome(); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('renders the Features & AI section header', () => { + renderSettingsHome(); + expect(screen.getByText('Features & AI')).toBeInTheDocument(); + }); + + it('renders the Billing & Rewards section header', () => { + renderSettingsHome(); + expect(screen.getByText('Billing & Rewards')).toBeInTheDocument(); + }); + + it('renders the Support section header', () => { + renderSettingsHome(); + expect(screen.getByText('Support')).toBeInTheDocument(); + }); + + it('renders the Advanced section header', () => { + renderSettingsHome(); + expect(screen.getByText('Advanced')).toBeInTheDocument(); + }); + + it('renders the Danger Zone section header', () => { + renderSettingsHome(); + expect(screen.getByText('Danger Zone')).toBeInTheDocument(); + }); + }); + + describe('item grouping order', () => { + it('places Account and Notifications under General', () => { + renderSettingsHome(); + const generalHeader = screen.getByText('General'); + const accountItem = screen.getByText('Account'); + const notificationsItem = screen.getByText('Notifications'); + + // All should appear after the General header in DOM order + expect(generalHeader.compareDocumentPosition(accountItem)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(generalHeader.compareDocumentPosition(notificationsItem)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it('places Features, AI & Models under Features & AI', () => { + renderSettingsHome(); + const header = screen.getByText('Features & AI'); + expect(header.compareDocumentPosition(screen.getByText('Features'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(header.compareDocumentPosition(screen.getByText('AI & Models'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it('places Billing & Usage and Rewards under Billing & Rewards', () => { + renderSettingsHome(); + const header = screen.getByText('Billing & Rewards'); + expect(header.compareDocumentPosition(screen.getByText('Billing & Usage'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(header.compareDocumentPosition(screen.getByText('Rewards'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it('places Restart Tour and About under Support', () => { + renderSettingsHome(); + const header = screen.getByText('Support'); + expect(header.compareDocumentPosition(screen.getByText('Restart Tour'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(header.compareDocumentPosition(screen.getByText('About'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it('places Developer Options under Advanced', () => { + renderSettingsHome(); + const header = screen.getByText('Advanced'); + expect(header.compareDocumentPosition(screen.getByText('Developer Options'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + it('places Clear App Data and Log out under Danger Zone', () => { + renderSettingsHome(); + const header = screen.getByText('Danger Zone'); + expect(header.compareDocumentPosition(screen.getByText('Clear App Data'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + expect(header.compareDocumentPosition(screen.getByText('Log out'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + }); + + describe('Rewards menu item', () => { + it('renders the Rewards item', () => { + renderSettingsHome(); + expect(screen.getByText('Rewards')).toBeInTheDocument(); + }); + + it('navigates to /rewards when clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + // The Rewards item description is used to find the right button + const rewardsButton = screen.getByText('Rewards').closest('button'); + expect(rewardsButton).toBeTruthy(); + await user.click(rewardsButton!); + + expect(mockNavigate).toHaveBeenCalledWith('/rewards'); + }); + }); + + describe('existing navigation items', () => { + it('navigates to account settings when Account is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Account').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('account'); + }); + + it('navigates to notifications settings when Notifications is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Notifications').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications'); + }); + + it('navigates to /home when Restart Tour is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Restart Tour').closest('button')!); + expect(mockNavigate).toHaveBeenCalledWith('/home'); + }); + + it('navigates to features settings when Features is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Features').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('features'); + }); + + it('navigates to ai-models settings when AI & Models is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('AI & Models').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('ai-models'); + }); + + it('opens billing URL when Billing & Usage is clicked', async () => { + const { openUrl } = await import('../../../utils/openUrl'); + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Billing & Usage').closest('button')!); + expect(openUrl).toHaveBeenCalledWith('https://billing.example.com'); + }); + + it('navigates to about settings when About is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('About').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('about'); + }); + + it('navigates to developer-options settings when Developer Options is clicked', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Developer Options').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('developer-options'); + }); + }); +});