feat(settings): group settings menu into labeled sections (#1286)

This commit is contained in:
Cyrus Gray
2026-05-07 14:31:04 +05:30
committed by GitHub
parent 22946a19db
commit 894485a0cd
2 changed files with 460 additions and 155 deletions
+212 -155
View File
@@ -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 }) => (
<div className="px-4 pt-5 pb-1">
<span className="text-[10px] font-semibold tracking-widest uppercase text-stone-400">
{label}
</span>
</div>
);
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
onClick: () => navigateToSettings('account'),
dangerous: false,
label: 'General',
items: [
{
id: 'account',
title: 'Account',
description: 'Recovery phrase, team, connections, and privacy',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
onClick: () => navigateToSettings('account'),
},
{
id: 'notifications',
title: 'Notifications',
description: 'Do Not Disturb and per-account notification controls',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
),
onClick: () => navigateToSettings('notifications'),
},
],
},
{
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, credits, and payment methods',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
onClick: () => {
void openUrl(BILLING_DASHBOARD_URL);
},
dangerous: false,
label: 'Features & AI',
items: [
{
id: 'features',
title: 'Features',
description: 'Screen awareness, messaging, and tools',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
onClick: () => navigateToSettings('features'),
},
{
id: 'ai-models',
title: 'AI & Models',
description: 'Local AI model setup and downloads',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
onClick: () => navigateToSettings('ai-models'),
},
],
},
{
id: 'features',
title: 'Features',
description: 'Screen awareness, messaging, and tools',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
onClick: () => navigateToSettings('features'),
dangerous: false,
label: 'Billing & Rewards',
items: [
{
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, credits, and payment methods',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
onClick: () => {
void openUrl(BILLING_DASHBOARD_URL);
},
},
{
id: 'rewards',
title: 'Rewards',
description: 'Referrals, coupons, and earned credits',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
),
onClick: () => navigate('/rewards'),
},
],
},
{
id: 'ai-models',
title: 'AI & Models',
description: 'Local AI model setup and downloads',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
),
onClick: () => navigateToSettings('ai-models'),
dangerous: false,
label: 'Support',
items: [
{
id: 'restart-tour',
title: 'Restart Tour',
description: 'Replay the product walkthrough from the beginning',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
),
onClick: () => {
resetWalkthrough();
navigate('/home');
},
},
{
id: 'about',
title: 'About',
description: 'App version and software updates',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
onClick: () => navigateToSettings('about'),
},
],
},
{
id: 'notifications',
title: 'Notifications',
description: 'Do Not Disturb and per-account notification controls',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
),
onClick: () => navigateToSettings('notifications'),
dangerous: false,
},
{
id: 'restart-tour',
title: 'Restart Tour',
description: 'Replay the product walkthrough from the beginning',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
),
onClick: () => {
resetWalkthrough();
navigate('/home');
},
dangerous: false,
},
{
id: 'about',
title: 'About',
description: 'App version and software updates',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
onClick: () => navigateToSettings('about'),
dangerous: false,
},
{
id: 'developer-options',
title: 'Developer Options',
description: 'Diagnostics, debug panels, webhooks, and memory inspection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
),
onClick: () => navigateToSettings('developer-options'),
dangerous: false,
label: 'Advanced',
items: [
{
id: 'developer-options',
title: 'Developer Options',
description: 'Diagnostics, debug panels, webhooks, and memory inspection',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
),
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 = () => {
</div>
<div>
{/* Grouped Settings */}
{groupedMenuItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
dangerous={item.dangerous}
isFirst={index === 0}
// isLast={index === groupedMenuItems.length - 1}
/>
{/* Grouped sections with section headers */}
{settingsSections.map(section => (
<div key={section.label}>
<SectionHeader label={section.label} />
{section.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === section.items.length - 1}
/>
))}
</div>
))}
{/* Destructive Actions */}
{destructiveMenuItems.map((item, index) => (
{/* Danger Zone */}
<SectionHeader label="Danger Zone" />
{destructiveItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
@@ -311,7 +368,7 @@ const SettingsHome = () => {
onClick={item.onClick}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === destructiveMenuItems.length - 1}
isLast={index === destructiveItems.length - 1}
/>
))}
</div>
@@ -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<typeof import('react-router-dom')>();
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(
<MemoryRouter>
<SettingsHome />
</MemoryRouter>
);
}
// --- 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');
});
});
});