diff --git a/CHANGELOG.md b/CHANGELOG.md index fced98ed9d..877a50a099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased - Bitcoin: show account details for the persisted receive address type by default +- Settings search ## v4.51.1 - iOS: fix App Store submission by packaging Inter as TTF fonts diff --git a/frontends/web/src/components/forms/search-input.module.css b/frontends/web/src/components/forms/search-input.module.css index 6af4680cf0..d14d946058 100644 --- a/frontends/web/src/components/forms/search-input.module.css +++ b/frontends/web/src/components/forms/search-input.module.css @@ -6,3 +6,23 @@ pointer-events: none; width: 20px; } + +.clearButton { + align-items: center; + background: transparent; + border: none; + cursor: pointer; + display: flex; + flex: none; + height: 24px; + justify-content: center; + margin: auto 0; + padding: 0; + width: 24px; +} + +.clearButton img, +.clearButton svg { + height: 20px; + width: 20px; +} diff --git a/frontends/web/src/components/forms/search-input.tsx b/frontends/web/src/components/forms/search-input.tsx index 5da2609848..8f906775df 100644 --- a/frontends/web/src/components/forms/search-input.tsx +++ b/frontends/web/src/components/forms/search-input.tsx @@ -1,13 +1,61 @@ // SPDX-License-Identifier: Apache-2.0 import { forwardRef } from 'react'; -import { Loupe } from '@/components/icon'; +import { CloseXDark, CloseXWhite, Loupe } from '@/components/icon'; import { Input } from './input'; import type { TInputProps } from './input'; import styles from './search-input.module.css'; -export const SearchInput = forwardRef((props, ref) => ( - - - -)); +type TBaseProps = Omit; + +type TClearProps = TBaseProps & { + clearButtonLabel?: string; + onClear: () => void; + variant: 'clear'; +}; + +type TSearchProps = TBaseProps & { + clearButtonLabel?: never; + onClear?: never; + variant?: 'search'; +}; + +type TProps = TClearProps | TSearchProps; + +export const SearchInput = forwardRef((props, ref) => { + if (props.variant === 'clear') { + const { + clearButtonLabel, + onClear, + variant: _variant, + ...inputProps + } = props; + + return ( + + {String(inputProps.value ?? '').trim().length > 0 ? ( + + ) : null} + + ); + } + + const { + variant: _variant, + ...inputProps + } = props; + + return ( + + + + ); +}); diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index f725aed8d6..0c83f23517 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1988,6 +1988,9 @@ "title": "Manage notes" }, "restart": "Please re-start the BitBoxApp for the changes to take effect.", + "search": { + "noResults": "No settings found" + }, "services": { "title": "Services" }, diff --git a/frontends/web/src/routes/device/no-device-connected.tsx b/frontends/web/src/routes/device/no-device-connected.tsx index 99cc5d17c4..205bc44c66 100644 --- a/frontends/web/src/routes/device/no-device-connected.tsx +++ b/frontends/web/src/routes/device/no-device-connected.tsx @@ -12,10 +12,12 @@ import { WithSettingsTabs } from '@/routes/settings/components/tabs'; import { ManageDeviceGuide } from './bitbox02/settings-guide'; import styles from './no-device-connected.module.css'; +type TProps = TPagePropsWithSettingsTabs; + export const NoDeviceConnected = ({ devices, hasAccounts, -}: TPagePropsWithSettingsTabs) => { +}: TProps) => { const { t } = useTranslation(); return ( diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index db58213964..76304cf6e4 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -87,6 +87,16 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp ); + const NoAccounts = ( + + + + ); + const Acc = ( - + diff --git a/frontends/web/src/routes/settings/about.tsx b/frontends/web/src/routes/settings/about.tsx index 68d98d4761..5cefc73a69 100644 --- a/frontends/web/src/routes/settings/about.tsx +++ b/frontends/web/src/routes/settings/about.tsx @@ -13,6 +13,7 @@ import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; import { GlobalBanners } from '@/components/banners'; import { FeedbackLink } from './components/about/feedback-link-setting'; import { SupportLink } from './components/about/support-link-setting'; +import { SettingsContent, type TSettingsContentSection } from './components/settings-content'; export const About = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); @@ -34,9 +35,7 @@ export const About = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { - - - + @@ -47,6 +46,20 @@ export const About = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { ); }; +export const AboutSettingsContent = () => { + const sections: TSettingsContentSection[] = [ + { + id: 'about', + items: [ + { id: 'app-version', content: }, + { id: 'feedback', content: }, + { id: 'support', content: }, + ], + }, + ]; + + return ; +}; const AboutGuide = () => { const { t } = useTranslation(); diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 2482cf13bd..0f3a870ba1 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -22,6 +22,7 @@ import { Entry } from '@/components/guide/entry'; import { EnableAuthSetting } from './components/advanced-settings/enable-auth-setting'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; import { GlobalBanners } from '@/components/banners'; +import { SettingsContent, type TSettingsContentSection } from './components/settings-content'; export type TProxyConfig = { proxyAddress: string; @@ -46,21 +47,12 @@ export type TConfig = { frontend?: TFrontendConfig; }; +type TProps = { + devices: TPagePropsWithSettingsTabs['devices']; +}; + export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); - const fetchedConfig = useLoad(getConfig) as TConfig; - const [config, setConfig] = useState(); - - const frontendConfig = config?.frontend; - const backendConfig = config?.backend; - const proxyConfig = config?.backend?.proxy; - - useEffect(() => { - setConfig(fetchedConfig); - }, [fetchedConfig]); - - const deviceIDs = Object.keys(devices); - return ( @@ -83,15 +75,7 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting hideMobileMenu hasAccounts={hasAccounts} > - - - - - - - - - + @@ -103,6 +87,41 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting ); }; +export const AdvancedSettingsContent = ({ + devices, +}: TProps) => { + const fetchedConfig = useLoad(getConfig) as TConfig; + const [config, setConfig] = useState(); + + const frontendConfig = config?.frontend; + const backendConfig = config?.backend; + const proxyConfig = config?.backend?.proxy; + + useEffect(() => { + setConfig(fetchedConfig); + }, [fetchedConfig]); + + const deviceIDs = Object.keys(devices); + const sections: TSettingsContentSection[] = [ + { + id: 'advanced-settings', + items: [ + { id: 'custom-fees', content: }, + { id: 'coin-control', content: }, + { id: 'screen-lock', content: }, + { id: 'tor-proxy', content: }, + { id: 'testnet-mode', content: }, + { id: 'gap-limit', content: }, + { id: 'test-wallet', content: }, + { id: 'full-node', content: }, + { id: 'export-logs', content: }, + ], + }, + ]; + + return ; +}; + const AdvancedSettingsGuide = () => { const { t } = useTranslation(); diff --git a/frontends/web/src/routes/settings/bb02-settings.module.css b/frontends/web/src/routes/settings/bb02-settings.module.css index e4de1ab837..de198f7393 100644 --- a/frontends/web/src/routes/settings/bb02-settings.module.css +++ b/frontends/web/src/routes/settings/bb02-settings.module.css @@ -1,11 +1,3 @@ -.section { - margin-bottom: var(--space-default) -} - -.section h3 { - margin-bottom: var(--space-half) -} - .skeletonWrapper { margin-bottom: var(--space-half); } @@ -15,8 +7,4 @@ .skeletonWrapper { margin-bottom: 2px; } - - .withMobilePadding { - padding: 0 var(--space-half); - } -} \ No newline at end of file +} diff --git a/frontends/web/src/routes/settings/bb02-settings.tsx b/frontends/web/src/routes/settings/bb02-settings.tsx index 81e1c9d0a5..77722e9710 100644 --- a/frontends/web/src/routes/settings/bb02-settings.tsx +++ b/frontends/web/src/routes/settings/bb02-settings.tsx @@ -29,14 +29,17 @@ import { ManageDeviceGuide } from '@/routes/device/bitbox02/settings-guide'; import { MobileHeader } from './components/mobile-header'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; import { GlobalBanners } from '@/components/banners'; +import { SettingsContent, type TSettingsContentSection } from './components/settings-content'; import { SubTitle } from '@/components/title'; import styles from './bb02-settings.module.css'; -type TProps = { +type TCommonProps = { deviceID: string; }; -type TWrapperProps = TProps & TPagePropsWithSettingsTabs; +type TWrapperProps = TCommonProps & TPagePropsWithSettingsTabs; + +type TProps = TCommonProps; export const StyledSkeleton = () => { return ( @@ -70,7 +73,7 @@ const BB02Settings = ({ deviceID, devices, hasAccounts }: TWrapperProps) => { hideMobileMenu hasAccounts={hasAccounts} > - + @@ -81,7 +84,9 @@ const BB02Settings = ({ deviceID, devices, hasAccounts }: TWrapperProps) => { ); }; -const Content = ({ deviceID }: TProps) => { +export const ManageDeviceSettingsContent = ({ + deviceID, +}: TProps) => { const { t } = useTranslation(); const [deviceInfo, setDeviceInfo] = useState(); @@ -100,104 +105,121 @@ const Content = ({ deviceID }: TProps) => { .catch(console.error); }, [deviceID, t]); - return ( - <> - {/*"Backups" section*/} -
- {t('deviceSettings.backups.title')} - - -
+ const hasBluetooth = !!deviceInfo?.bluetooth; + const canToggleBluetooth = hasBluetooth && !runningInIOS(); - {/*"Device settings" section*/} -
- {t('deviceSettings.deviceSettings.title')} - {deviceInfo ? ( - - ) : - - } - { deviceInfo && deviceInfo.bluetooth && !runningInIOS() - ? - : null - } + const sections: TSettingsContentSection[] = [ + { + id: 'backups', + items: [ + { id: 'manage-backups', content: }, + { id: 'show-recovery-words', content: }, + ], + title: {t('deviceSettings.backups.title')}, + }, + { + id: 'device-settings', + items: [ + { + id: 'device-name', + content: deviceInfo ? ( + + ) : ( + + ), + }, + ...(canToggleBluetooth ? [{ + id: 'bluetooth', + content: , + }] : []), { - versionInfo ? ( + id: 'device-password', + content: versionInfo ? ( ) : ( - ) - } -
- - {/*"Device information" section*/} -
- {t('deviceSettings.deviceInformation.title')} + ), + }, + ], + title: {t('deviceSettings.deviceSettings.title')}, + }, + { + id: 'device-information', + items: [ { - versionInfo ? ( + id: 'firmware', + content: versionInfo ? ( - ) : + ) : ( - } - { - deviceInfo && deviceInfo.bluetooth ? ( + ), + }, + ...(hasBluetooth && deviceInfo?.bluetooth ? [{ + id: 'bluetooth-firmware', + content: ( - ) : null - } - + ), + }] : []), + { id: 'authenticity-check', content: }, { - rootFingerprintResult && rootFingerprintResult.success ? - - : - - } + id: 'root-fingerprint', + content: rootFingerprintResult && rootFingerprintResult.success + ? + : , + }, { - deviceInfo && deviceInfo.securechipModel !== '' ? - - : - - } -
- - {/*"Expert settings" section*/} -
- {t('settings.expert.title')} + id: 'secure-chip', + content: deviceInfo && deviceInfo.securechipModel !== '' + ? + : , + }, + ], + title: {t('deviceSettings.deviceInformation.title')}, + }, + { + id: 'expert-settings', + items: [ { - deviceInfo ? ( + id: 'passphrase', + content: deviceInfo ? ( ) : ( - ) - } + ), + }, { - versionInfo ? ( + id: 'bip85', + content: versionInfo ? ( ) : ( - ) - } - - -
- - ); + ), + }, + { id: 'startup-settings', content: }, + { id: 'factory-reset', content: }, + ], + title: {t('settings.expert.title')}, + }, + ]; + + return ; }; export { BB02Settings }; diff --git a/frontends/web/src/routes/settings/components/settings-content.module.css b/frontends/web/src/routes/settings/components/settings-content.module.css new file mode 100644 index 0000000000..22c0c2b39c --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-content.module.css @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +.highlightedItem { + animation: settings-highlight-pulse 800ms ease-in-out 3; + border-radius: var(--radius-xl); + outline: 2px solid transparent; + outline-offset: 2px; +} + +@keyframes settings-highlight-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(var(--color-blue-rgb), 0); + outline-color: transparent; + } + + 50% { + box-shadow: 0 0 0 4px rgba(var(--color-blue-rgb), 0.18); + outline-color: var(--color-primary); + } +} diff --git a/frontends/web/src/routes/settings/components/settings-content.test.tsx b/frontends/web/src/routes/settings/components/settings-content.test.tsx new file mode 100644 index 0000000000..94acfafcb2 --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-content.test.tsx @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SettingsContent, type TSettingsContentSection } from './settings-content'; +import { SETTINGS_HIGHLIGHT_QUERY_PARAM } from '../settings-search'; + +const sections: TSettingsContentSection[] = [ + { + id: 'general', + items: [ + { id: 'language', content:
Language setting
}, + { id: 'currency', content:
Currency setting
}, + ], + title: 'General', + }, + { + id: 'device', + items: [ + { id: 'firmware', content:
Firmware setting
}, + ], + title: 'Device', + }, +]; + +const renderSettingsContent = ( + initialEntry = '/settings/general', + contentSections = sections, + onLocationChange?: (search: string) => void, +) => render( + + + + +); + +const LocationObserver = ({ + onLocationChange, +}: { + onLocationChange?: (search: string) => void; +}) => { + const location = useLocation(); + onLocationChange?.(location.search); + return null; +}; + +describe('SettingsContent', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + }); + + it('renders all items normally', () => { + renderSettingsContent(); + + expect(screen.getByText('Language setting')).toBeInTheDocument(); + expect(screen.getByText('Currency setting')).toBeInTheDocument(); + expect(screen.getByText('Firmware setting')).toBeInTheDocument(); + }); + + it('hides empty sections', () => { + renderSettingsContent('/settings/general', [ + { + id: 'empty', + items: [], + title: 'Empty section', + }, + sections[0] as TSettingsContentSection, + ]); + + expect(screen.queryByText('Empty section')).not.toBeInTheDocument(); + expect(screen.getByText('General')).toBeInTheDocument(); + }); + + it('highlights only the matching item', () => { + renderSettingsContent(`/settings/general?${SETTINGS_HIGHLIGHT_QUERY_PARAM}=currency`); + + expect(screen.getByText('Currency setting').parentElement?.className).not.toBe(''); + expect(screen.getByText('Language setting').parentElement?.className).toBe(''); + expect(screen.getByText('Firmware setting').parentElement?.className).toBe(''); + }); + + it('scrolls the highlighted item into view', async () => { + renderSettingsContent(`/settings/general?${SETTINGS_HIGHLIGHT_QUERY_PARAM}=currency`); + + await waitFor(() => { + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + }); + + it('removes the highlight query param after reading it', async () => { + let currentSearch = ''; + renderSettingsContent( + `/settings/general?${SETTINGS_HIGHLIGHT_QUERY_PARAM}=currency`, + sections, + search => { + currentSearch = search; + }, + ); + + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(screen.getByText('Currency setting').parentElement?.className).not.toBe(''); + }); +}); diff --git a/frontends/web/src/routes/settings/components/settings-content.tsx b/frontends/web/src/routes/settings/components/settings-content.tsx new file mode 100644 index 0000000000..cac614606e --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-content.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Fragment, ReactNode, useEffect, useRef } from 'react'; +import { useSettingsHighlight } from '../use-settings-highlight'; +import styles from './settings-content.module.css'; + +export type TSettingsContentItem = { + content: ReactNode; + id: string; +}; + +export type TSettingsContentSection = { + id: string; + items: TSettingsContentItem[]; + title?: ReactNode; +}; + +type TProps = { + sections: TSettingsContentSection[]; +}; + +export const SettingsContent = ({ + sections, +}: TProps) => { + const highlightedItemID = useSettingsHighlight(); + const highlightedItemRef = useRef(null); + const scrolledItemID = useRef(); + + useEffect(() => { + if (!highlightedItemID) { + scrolledItemID.current = undefined; + return; + } + + if (!highlightedItemRef.current || scrolledItemID.current === highlightedItemID) { + return; + } + + const scrollToHighlightedItem = () => { + highlightedItemRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }; + + [0, 100, 300].forEach(delay => { + window.setTimeout(scrollToHighlightedItem, delay); + }); + scrolledItemID.current = highlightedItemID; + }); + + return ( + <> + {sections.map(section => { + if (section.items.length === 0) { + return null; + } + + return ( + + {section.title ? section.title : null} + {section.items.map(settingItem => { + const isHighlighted = settingItem.id === highlightedItemID; + + return ( +
+ {settingItem.content} +
+ ); + })} +
+ ); + })} + + ); +}; diff --git a/frontends/web/src/routes/settings/components/settings-search-content.module.css b/frontends/web/src/routes/settings/components/settings-search-content.module.css new file mode 100644 index 0000000000..8c30e540c2 --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-search-content.module.css @@ -0,0 +1,18 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +.container { + margin-bottom: var(--space-default); +} + +.empty { + color: var(--color-secondary); + margin: 0 0 var(--space-default); +} + +.pageGroup { + margin-bottom: var(--space-default); +} + +.pageTitle { + margin-top: var(--space-default); +} diff --git a/frontends/web/src/routes/settings/components/settings-search-content.test.tsx b/frontends/web/src/routes/settings/components/settings-search-content.test.tsx new file mode 100644 index 0000000000..127fa6bb14 --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-search-content.test.tsx @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TDevices } from '@/api/devices'; +import { + SETTINGS_HIGHLIGHT_QUERY_PARAM, + type TSettingsSearchItem, +} from '../settings-search'; +import { SettingsSearchContent } from './settings-search-content'; + +const translations: Record = { + 'settings.about': 'About', + 'settings.advancedSettings': 'Advanced settings', + 'settings.general': 'General', + 'settings.search.noResults': 'No settings found', + 'sidebar.device': 'Manage device', +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => translations[key] || key, + }), +})); + +const renderSettingsSearchContent = ({ + devices = {}, + searchResults, + onLocationChange, +}: { + devices?: TDevices; + searchResults: TSettingsSearchItem[]; + onLocationChange?: (location: ReturnType) => void; +}) => { + const LocationObserver = () => { + const location = useLocation(); + onLocationChange?.(location); + return null; + }; + + return render( + + + + + ); +}; + +describe('SettingsSearchContent', () => { + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + value: vi.fn().mockImplementation(query => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: query, + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })), + writable: true, + }); + }); + + it('renders clickable search result rows', () => { + renderSettingsSearchContent({ + searchResults: [{ + id: 'language', + page: 'general', + title: 'Language', + }], + }); + + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Language' })).toBeInTheDocument(); + }); + + it('navigates to general settings with the highlighted item', () => { + let currentPath = ''; + let currentSearch = ''; + renderSettingsSearchContent({ + onLocationChange: location => { + currentPath = location.pathname; + currentSearch = location.search; + }, + searchResults: [{ + id: 'language', + page: 'general', + title: 'Language', + }], + }); + + fireEvent.click(screen.getByRole('button', { name: 'Language' })); + + expect(currentPath).toBe('/settings/general'); + expect(currentSearch).toBe(`?${SETTINGS_HIGHLIGHT_QUERY_PARAM}=language`); + }); + + it('navigates device results to the first connected device', () => { + let currentPath = ''; + let currentSearch = ''; + renderSettingsSearchContent({ + devices: { + firstDeviceID: 'bitbox02', + }, + onLocationChange: location => { + currentPath = location.pathname; + currentSearch = location.search; + }, + searchResults: [{ + id: 'firmware', + page: 'device', + title: 'Firmware', + }], + }); + + fireEvent.click(screen.getByRole('button', { name: 'Firmware' })); + + expect(currentPath).toBe('/settings/device-settings/firstDeviceID'); + expect(currentSearch).toBe(`?${SETTINGS_HIGHLIGHT_QUERY_PARAM}=firmware`); + }); + + it('shows the no-results text', () => { + renderSettingsSearchContent({ + searchResults: [], + }); + + expect(screen.getByText('No settings found')).toBeInTheDocument(); + }); +}); diff --git a/frontends/web/src/routes/settings/components/settings-search-content.tsx b/frontends/web/src/routes/settings/components/settings-search-content.tsx new file mode 100644 index 0000000000..1eac0a49dc --- /dev/null +++ b/frontends/web/src/routes/settings/components/settings-search-content.tsx @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import type { TDevices } from '@/api/devices'; +import { SubTitle } from '@/components/title'; +import { + SETTINGS_HIGHLIGHT_QUERY_PARAM, + SETTINGS_SEARCH_PAGE_ORDER, + groupSearchResultsByPage, + type TSettingsSearchItem, + type TSettingsSearchPage, +} from '../settings-search'; +import { SettingsItem } from './settingsItem/settingsItem'; +import styles from './settings-search-content.module.css'; + +type TProps = { + devices: TDevices; + searchResults: TSettingsSearchItem[]; +}; + +const getSettingsSearchResultURL = ( + searchResult: TSettingsSearchItem, + firstDeviceID?: string, +) => { + const searchParams = new URLSearchParams({ + [SETTINGS_HIGHLIGHT_QUERY_PARAM]: searchResult.id, + }); + + if (searchResult.page === 'device') { + return firstDeviceID ? `/settings/device-settings/${firstDeviceID}?${searchParams}` : undefined; + } + + const pageURLs: Record, string> = { + about: '/settings/about', + advanced: '/settings/advanced-settings', + general: '/settings/general', + }; + + return `${pageURLs[searchResult.page]}?${searchParams}`; +}; + +export const SettingsSearchContent = ({ + devices, + searchResults, +}: TProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const firstDeviceID = Object.keys(devices)[0]; + + const pageTitles: Record = { + about: t('settings.about'), + advanced: t('settings.advancedSettings'), + device: t('sidebar.device'), + general: t('settings.general'), + }; + + const searchResultsByPage = groupSearchResultsByPage(searchResults); + + return ( +
+ {searchResults.length === 0 ? ( +

+ {t('settings.search.noResults')} +

+ ) : ( + SETTINGS_SEARCH_PAGE_ORDER.map(page => { + const pageSearchResults = searchResultsByPage[page]; + const navigableResults = pageSearchResults + .map(searchResult => ({ + searchResult, + url: getSettingsSearchResultURL(searchResult, firstDeviceID), + })) + .filter( + (item): item is { searchResult: TSettingsSearchItem; url: string } => item.url !== undefined, + ); + + if (navigableResults.length === 0) { + return null; + } + + return ( +
+ {pageTitles[page]} + {navigableResults.map(({ searchResult, url }) => { + return ( + navigate(url)} + settingName={searchResult.title} + /> + ); + })} +
+ ); + }) + )} +
+ ); +}; diff --git a/frontends/web/src/routes/settings/components/tabs.module.css b/frontends/web/src/routes/settings/components/tabs.module.css index e927b535e8..d741325440 100644 --- a/frontends/web/src/routes/settings/components/tabs.module.css +++ b/frontends/web/src/routes/settings/components/tabs.module.css @@ -4,6 +4,10 @@ word-break: keep-all; } +.searchContainer { + margin-bottom: var(--space-default); +} + .container a { margin-right: var(--space-default); font-size: var(--size-subheader); diff --git a/frontends/web/src/routes/settings/components/tabs.tsx b/frontends/web/src/routes/settings/components/tabs.tsx index 0daad0926a..1a87dec474 100644 --- a/frontends/web/src/routes/settings/components/tabs.tsx +++ b/frontends/web/src/routes/settings/components/tabs.tsx @@ -8,6 +8,9 @@ import { useLoad } from '@/hooks/api'; import { getVersion } from '@/api/bitbox02'; import { useDarkmode } from '@/hooks/darkmode'; import { SettingsItem } from './settingsItem/settingsItem'; +import { SettingsSearchContent } from './settings-search-content'; +import { SearchInput } from '@/components/forms'; +import { useSettingsSearch } from '../use-settings-search'; import { AdvancedSettingsIcon, AdvancedSettingsIconDark, @@ -29,6 +32,7 @@ type TWithSettingsTabsProps = { devices: TDevices; hasAccounts: boolean; hideMobileMenu?: boolean; + renderDefaultTabs?: boolean; }; type TTab = { @@ -50,17 +54,48 @@ export const WithSettingsTabs = ({ devices, hideMobileMenu, hasAccounts, + renderDefaultTabs = true, }: TWithSettingsTabsProps) => { + const { t } = useTranslation(); + const isMobile = useMediaQuery('(max-width: 768px)'); + const { + searchResults, + searchTerm, + showSearchResults, + updateSearchTerm, + } = useSettingsSearch({ + devices, + hasAccounts, + }); + return ( <> -
- + updateSearchTerm(event.currentTarget.value)} + onClear={() => updateSearchTerm('')} + placeholder={t('generic.search')} + value={searchTerm} + variant="clear" />
- {children} + {renderDefaultTabs ? ( +
+ +
+ ) : null} + {showSearchResults ? ( + + ) : children} ); }; diff --git a/frontends/web/src/routes/settings/general.module.css b/frontends/web/src/routes/settings/general.module.css deleted file mode 100644 index c13dc77588..0000000000 --- a/frontends/web/src/routes/settings/general.module.css +++ /dev/null @@ -1,5 +0,0 @@ -@media (max-width: 768px) { - .subtitleWithMobilePadding { - padding: 0 var(--space-half); - } -} \ No newline at end of file diff --git a/frontends/web/src/routes/settings/general.tsx b/frontends/web/src/routes/settings/general.tsx index 0d547b2952..724acef401 100644 --- a/frontends/web/src/routes/settings/general.tsx +++ b/frontends/web/src/routes/settings/general.tsx @@ -13,11 +13,15 @@ import { WithSettingsTabs } from './components/tabs'; import { MobileHeader } from './components/mobile-header'; import { Guide } from '@/components/guide/guide'; import { Entry } from '@/components/guide/entry'; +import { SettingsContent, type TSettingsContentSection } from './components/settings-content'; import { SubTitle } from '@/components/title'; import { TPagePropsWithSettingsTabs } from './types'; import { GlobalBanners } from '@/components/banners'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; -import style from './general.module.css'; + +type TProps = { + hasAccounts: boolean; +}; export const General = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); @@ -39,22 +43,7 @@ export const General = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => - - {t('settings.appearance')} - - - - - - { hasAccounts ? ( - <> - - {t('settings.notes.title')} - - - - - ) : null } + @@ -66,6 +55,37 @@ export const General = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => ); }; +export const GeneralSettingsContent = ({ + hasAccounts, +}: TProps) => { + const { t } = useTranslation(); + + const sections: TSettingsContentSection[] = [ + { + id: 'appearance', + items: [ + { id: 'language', content: }, + { id: 'default-currency', content: }, + { id: 'active-currencies', content: }, + { id: 'dark-mode', content: }, + ], + title: {t('settings.appearance')}, + }, + ...(hasAccounts ? [{ + id: 'notes', + items: [ + { id: 'export-notes', content: }, + { id: 'import-notes', content: }, + ], + title: {t('settings.notes.title')}, + }] : []), + ]; + + return ( + + ); +}; + const GeneralGuide = () => { const { t } = useTranslation(); diff --git a/frontends/web/src/routes/settings/mobile-settings.tsx b/frontends/web/src/routes/settings/mobile-settings.tsx index f185b89a3c..db6ba5b027 100644 --- a/frontends/web/src/routes/settings/mobile-settings.tsx +++ b/frontends/web/src/routes/settings/mobile-settings.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { View, ViewContent } from '@/components/view/view'; import { Header, Main } from '@/components/layout'; -import { Tabs } from './components/tabs'; +import { Tabs, WithSettingsTabs } from './components/tabs'; import { TPagePropsWithSettingsTabs } from './types'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; import { GlobalBanners } from '@/components/banners'; @@ -41,7 +41,9 @@ export const MobileSettings = ({ devices, hasAccounts }: TPagePropsWithSettingsT } /> - + + + diff --git a/frontends/web/src/routes/settings/settings-search.test.ts b/frontends/web/src/routes/settings/settings-search.test.ts new file mode 100644 index 0000000000..5ea3e571f4 --- /dev/null +++ b/frontends/web/src/routes/settings/settings-search.test.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { TFunction } from 'i18next'; +import { describe, expect, it, vi } from 'vitest'; +import type { DeviceInfo } from '@/api/bitbox02'; +import type { TDevices } from '@/api/devices'; +import { + filterSettingsSearchItems, + getSettingsSearchItems, +} from './settings-search'; + +vi.mock('@/utils/env', () => ({ + debug: true, + runningInAndroid: () => false, + runningInIOS: () => false, +})); + +const translations: Record = { + 'backup.showMnemonic.title': 'Show recovery words', + 'backup.title': 'Manage backups', + 'bitbox02Settings.bluetoothToggleEnabled.titleEnabled': 'Disable Bluetooth', + 'bitbox02Settings.changePassword.title': 'Change device password', + 'bitbox02Settings.deviceName.input': 'BitBox name', + 'bitbox02Settings.gotoStartupSettings.title': 'Go to startup settings', + 'darkmode.toggle': 'Dark mode', + 'deviceSettings.bluetoothFirmware.title': 'Bluetooth firmware', + 'deviceSettings.expert.bip85.title': 'Show BIP-85 child key', + 'deviceSettings.expert.factoryReset.title': 'Factory reset', + 'deviceSettings.expert.passphrase.title': 'Passphrase', + 'deviceSettings.firmware.title': 'Firmware', + 'deviceSettings.hardware.attestation.label': 'Authenticity check', + 'deviceSettings.hardware.securechip': 'Secure chip', + 'gapLimit.title': 'Custom gap limit settings', + 'newSettings.about.appVersion.title': 'App version', + 'newSettings.about.feedbackLink.title': 'Feedback', + 'newSettings.about.supportLink.title': 'Support', + 'newSettings.advancedSettings.authentication.title': 'Screen lock', + 'newSettings.appearance.activeCurrencies.title': 'Active currencies', + 'newSettings.appearance.defaultCurrency.title': 'Default currency', + 'newSettings.appearance.language.title': 'Language', + 'settings.about': 'About', + 'settings.advancedSettings': 'Advanced settings', + 'settings.expert.coinControl': 'Enable coin control', + 'settings.expert.electrum.title': 'Connect your own full node', + 'settings.expert.exportLogs.title': 'Export logs', + 'settings.expert.fee': 'Enable custom fees', + 'settings.expert.useProxy': 'Enable tor proxy', + 'settings.general': 'General', + 'settings.notes.export.title': 'Export notes', + 'settings.notes.import.title': 'Import notes', + 'sidebar.device': 'Manage device', + 'testWallet.connect.title': 'Test wallet', + 'testnet.activate.title': 'Start testnet mode', + 'testnet.deactivate.title': 'Exit testnet mode', +}; + +const t = ((key: string) => translations[key] || key) as TFunction; + +const deviceInfoWithoutBluetooth: DeviceInfo = { + bluetooth: null, + initialized: true, + mnemonicPassphraseEnabled: false, + name: 'BitBox02', + securechipModel: 'ATECC608B', + version: '9.0.0', +}; + +const deviceInfoWithBluetooth: DeviceInfo = { + ...deviceInfoWithoutBluetooth, + bluetooth: { + enabled: true, + firmwareVersion: '1.0.0', + }, +}; + +const getItems = (devices: TDevices = {}, deviceInfo?: DeviceInfo) => getSettingsSearchItems( + { + deviceInfo, + devices, + hasAccounts: true, + isTesting: false, + t, + } +); + +describe('settings search', () => { + it('matches case-insensitively and trims search text', () => { + const results = filterSettingsSearchItems(getItems(), ' DEFAULT Curr '); + + expect(results.map(result => result.title)).toEqual(['Default currency']); + }); + + it('matches localized setting title substrings', () => { + const results = filterSettingsSearchItems(getItems(), 'currency'); + + expect(results.map(result => result.title)).toEqual(['Default currency']); + }); + + it('does not include manage-device items without a device', () => { + const results = filterSettingsSearchItems(getItems(), 'manage backups'); + + expect(results).toEqual([]); + }); + + it('includes manage-device items for the first device', () => { + const results = filterSettingsSearchItems(getItems({ firstDeviceID: 'bitbox02' }), 'manage backups'); + + expect(results).toEqual([{ + id: 'manage-backups', + page: 'device', + title: 'Manage backups', + }]); + }); + + it('does not include bluetooth items when the device does not support bluetooth', () => { + const results = filterSettingsSearchItems( + getItems({ firstDeviceID: 'bitbox02' }, deviceInfoWithoutBluetooth), + 'bluetooth', + ); + + expect(results).toEqual([]); + }); + + it('includes bluetooth items when the device supports bluetooth', () => { + const results = filterSettingsSearchItems( + getItems({ firstDeviceID: 'bitbox02' }, deviceInfoWithBluetooth), + 'bluetooth', + ); + + expect(results.map(result => result.title)).toEqual([ + 'Disable Bluetooth', + 'Bluetooth firmware', + ]); + }); + + it('does not include export logs when the setting is hidden in debug builds', () => { + const results = filterSettingsSearchItems(getItems(), 'export logs'); + + expect(results).toEqual([]); + }); + + it('does not include screen lock when the setting is hidden outside mobile apps', () => { + const results = filterSettingsSearchItems(getItems(), 'screen lock'); + + expect(results).toEqual([]); + }); + + it('can return items from multiple pages', () => { + const results = filterSettingsSearchItems(getItems({ firstDeviceID: 'bitbox02' }), 'settings'); + + expect(results.map(result => result.page)).toEqual([ + 'advanced', + 'device', + ]); + expect(results.map(result => result.title)).toEqual([ + 'Custom gap limit settings', + 'Go to startup settings', + ]); + }); +}); diff --git a/frontends/web/src/routes/settings/settings-search.ts b/frontends/web/src/routes/settings/settings-search.ts new file mode 100644 index 0000000000..f28b48d1d2 --- /dev/null +++ b/frontends/web/src/routes/settings/settings-search.ts @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { TFunction } from 'i18next'; +import type { DeviceInfo } from '@/api/bitbox02'; +import type { TDevices } from '@/api/devices'; +import { debug, runningInAndroid, runningInIOS } from '@/utils/env'; + +export type TSettingsSearchItem = { + id: string; + title: string; + page: TSettingsSearchPage; +}; + +type TGetSettingsSearchItemsArgs = { + deviceInfo?: DeviceInfo; + devices: TDevices; + hasAccounts: boolean; + isTesting: boolean; + t: TFunction; +}; + +export type TSettingsSearchPage = 'general' | 'device' | 'advanced' | 'about'; + +export const SETTINGS_SEARCH_PAGE_ORDER: TSettingsSearchPage[] = ['general', 'device', 'advanced', 'about']; +export const SETTINGS_HIGHLIGHT_QUERY_PARAM = 'settingsHighlight'; + +type TSettingsSearchContext = TGetSettingsSearchItemsArgs & { + deviceIDs: string[]; +}; + +type TSettingsSearchDescriptor = { + id: string; + page: TSettingsSearchPage; + getTitle: (context: TSettingsSearchContext) => string; + isAvailable?: (context: TSettingsSearchContext) => boolean; +}; + + +const whenAccountsExist = ({ hasAccounts }: TSettingsSearchContext) => hasAccounts; +const whenDeviceExists = ({ deviceIDs }: TSettingsSearchContext) => deviceIDs.length > 0; +const whenDeviceSupportsBluetooth = ({ deviceInfo }: TSettingsSearchContext) => !!deviceInfo?.bluetooth; +const whenBluetoothToggleIsVisible = (context: TSettingsSearchContext) => ( + whenDeviceSupportsBluetooth(context) && !runningInIOS() +); +const whenExportLogsIsVisible = () => !debug; +const whenScreenLockIsVisible = () => runningInAndroid() || runningInIOS(); +const whenTestingWithoutDevice = ({ deviceIDs, isTesting }: TSettingsSearchContext) => ( + isTesting && deviceIDs.length === 0 +); + +const SETTINGS_SEARCH_DESCRIPTORS: TSettingsSearchDescriptor[] = [ + { + id: 'language', + getTitle: ({ t }) => t('newSettings.appearance.language.title'), + page: 'general', + }, + { + id: 'default-currency', + getTitle: ({ t }) => t('newSettings.appearance.defaultCurrency.title'), + page: 'general', + }, + { + id: 'active-currencies', + getTitle: ({ t }) => t('newSettings.appearance.activeCurrencies.title'), + page: 'general', + }, + { + id: 'dark-mode', + getTitle: ({ t }) => t('darkmode.toggle'), + page: 'general', + }, + { + id: 'custom-fees', + getTitle: ({ t }) => t('settings.expert.fee'), + page: 'advanced', + }, + { + id: 'coin-control', + getTitle: ({ t }) => t('settings.expert.coinControl'), + page: 'advanced', + }, + { + id: 'screen-lock', + isAvailable: whenScreenLockIsVisible, + getTitle: ({ t }) => t('newSettings.advancedSettings.authentication.title'), + page: 'advanced', + }, + { + id: 'tor-proxy', + getTitle: ({ t }) => t('settings.expert.useProxy'), + page: 'advanced', + }, + { + id: 'testnet-mode', + getTitle: ({ isTesting, t }) => isTesting ? t('testnet.deactivate.title') : t('testnet.activate.title'), + page: 'advanced', + }, + { + id: 'gap-limit', + getTitle: ({ t }) => t('gapLimit.title'), + page: 'advanced', + }, + { + id: 'full-node', + getTitle: ({ t }) => t('settings.expert.electrum.title'), + page: 'advanced', + }, + { + id: 'export-logs', + isAvailable: whenExportLogsIsVisible, + getTitle: ({ t }) => t('settings.expert.exportLogs.title'), + page: 'advanced', + }, + { + id: 'app-version', + getTitle: ({ t }) => t('newSettings.about.appVersion.title'), + page: 'about', + }, + { + id: 'feedback', + getTitle: ({ t }) => t('newSettings.about.feedbackLink.title'), + page: 'about', + }, + { + id: 'support', + getTitle: ({ t }) => t('newSettings.about.supportLink.title'), + page: 'about', + }, + { + id: 'export-notes', + isAvailable: whenAccountsExist, + getTitle: ({ t }) => t('settings.notes.export.title'), + page: 'general', + }, + { + id: 'import-notes', + isAvailable: whenAccountsExist, + getTitle: ({ t }) => t('settings.notes.import.title'), + page: 'general', + }, + { + id: 'test-wallet', + isAvailable: whenTestingWithoutDevice, + getTitle: ({ t }) => t('testWallet.connect.title'), + page: 'advanced', + }, + { + id: 'manage-backups', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('backup.title'), + page: 'device', + }, + { + id: 'show-recovery-words', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('backup.showMnemonic.title'), + page: 'device', + }, + { + id: 'device-name', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('bitbox02Settings.deviceName.input'), + page: 'device', + }, + { + id: 'bluetooth', + isAvailable: whenBluetoothToggleIsVisible, + getTitle: ({ t }) => t('bitbox02Settings.bluetoothToggleEnabled.titleEnabled'), + page: 'device', + }, + { + id: 'device-password', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('bitbox02Settings.changePassword.title'), + page: 'device', + }, + { + id: 'firmware', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.firmware.title'), + page: 'device', + }, + { + id: 'bluetooth-firmware', + isAvailable: whenDeviceSupportsBluetooth, + getTitle: ({ t }) => t('deviceSettings.bluetoothFirmware.title'), + page: 'device', + }, + { + id: 'authenticity-check', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.hardware.attestation.label'), + page: 'device', + }, + { + id: 'root-fingerprint', + isAvailable: whenDeviceExists, + getTitle: () => 'Root fingerprint', + page: 'device', + }, + { + id: 'secure-chip', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.hardware.securechip'), + page: 'device', + }, + { + id: 'passphrase', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.expert.passphrase.title'), + page: 'device', + }, + { + id: 'bip85', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.expert.bip85.title'), + page: 'device', + }, + { + id: 'startup-settings', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('bitbox02Settings.gotoStartupSettings.title'), + page: 'device', + }, + { + id: 'factory-reset', + isAvailable: whenDeviceExists, + getTitle: ({ t }) => t('deviceSettings.expert.factoryReset.title'), + page: 'device', + }, +]; + +export const getSettingsSearchItems = ({ + deviceInfo, + devices, + hasAccounts, + isTesting, + t, +}: TGetSettingsSearchItemsArgs): TSettingsSearchItem[] => { + const context = { + deviceInfo, + devices, + deviceIDs: Object.keys(devices), + hasAccounts, + isTesting, + t, + }; + + return SETTINGS_SEARCH_DESCRIPTORS + .filter(descriptor => descriptor.isAvailable ? descriptor.isAvailable(context) : true) + .map(descriptor => ({ + id: descriptor.id, + title: descriptor.getTitle(context), + page: descriptor.page, + })); +}; + + +export const filterSettingsSearchItems = ( + searchItems: TSettingsSearchItem[], + searchTerm: string, +): TSettingsSearchItem[] => { + const normalizedSearchTerm = searchTerm.trim().toLowerCase(); + if (!normalizedSearchTerm) { + return []; + } + return searchItems.filter(searchItem => ( + searchItem.title.trim().toLowerCase().includes(normalizedSearchTerm) + )); +}; + +export const groupSearchResultsByPage = (searchResults: TSettingsSearchItem[]) => { + const searchResultsByPage: Record = { + about: [], + advanced: [], + device: [], + general: [], + }; + + searchResults.forEach(searchResult => { + searchResultsByPage[searchResult.page].push(searchResult); + }); + + return searchResultsByPage; +}; + +export const SETTINGS_SEARCH_QUERY_PARAM = 'settingsSearch'; diff --git a/frontends/web/src/routes/settings/use-settings-highlight.ts b/frontends/web/src/routes/settings/use-settings-highlight.ts new file mode 100644 index 0000000000..87b17f8ce6 --- /dev/null +++ b/frontends/web/src/routes/settings/use-settings-highlight.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SETTINGS_HIGHLIGHT_QUERY_PARAM } from './settings-search'; + +const HIGHLIGHT_DURATION_MS = 2700; + +export const useSettingsHighlight = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const highlightedItemIDFromParams = searchParams.get(SETTINGS_HIGHLIGHT_QUERY_PARAM) || ''; + const [highlightedItemID, setHighlightedItemID] = useState(highlightedItemIDFromParams); + + useEffect(() => { + if (!highlightedItemIDFromParams) { + return; + } + + setHighlightedItemID(highlightedItemIDFromParams); + + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.delete(SETTINGS_HIGHLIGHT_QUERY_PARAM); + setSearchParams(nextSearchParams, { replace: true }); + }, [highlightedItemIDFromParams, searchParams, setSearchParams]); + + useEffect(() => { + if (!highlightedItemID) { + return; + } + + const timeout = window.setTimeout(() => { + setHighlightedItemID(''); + }, HIGHLIGHT_DURATION_MS); + + return () => { + window.clearTimeout(timeout); + }; + }, [highlightedItemID]); + + return highlightedItemID; +}; diff --git a/frontends/web/src/routes/settings/use-settings-search.test.tsx b/frontends/web/src/routes/settings/use-settings-search.test.tsx new file mode 100644 index 0000000000..0b6a9970eb --- /dev/null +++ b/frontends/web/src/routes/settings/use-settings-search.test.tsx @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TDevices } from '@/api/devices'; +import { AppContext } from '@/contexts/AppContext'; +import type { TSessionConfig } from '@/contexts/AppContext'; +import { SETTINGS_SEARCH_QUERY_PARAM } from './settings-search'; +import { useSettingsSearch } from './use-settings-search'; + +const translations: Record = { + 'backup.title': 'Manage backups', + 'newSettings.appearance.activeCurrencies.title': 'Active currencies', + 'newSettings.appearance.defaultCurrency.title': 'Default currency', + 'newSettings.appearance.language.title': 'Language', + 'settings.expert.fee': 'Enable custom fees', + 'testWallet.connect.title': 'Test wallet', +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => translations[key] || key, + }), +})); + +vi.mock('@/api/bitbox02', () => ({ + getDeviceInfo: vi.fn(), +})); + +const noop = () => {}; + +const createAppContext = (isTesting = false) => ({ + activeSidebar: false, + chartDisplay: 'week' as const, + firmwareUpdateDialogOpen: false, + guideExists: false, + guideShown: false, + hideAmounts: false, + isDevServers: false, + isOnline: true, + isTesting, + nativeLocale: 'en', + sessionConfig: {} as TSessionConfig, + setActiveSidebar: noop, + setChartDisplay: noop, + setFirmwareUpdateDialogOpen: noop, + setGuideExists: noop, + setHideAmounts: noop, + toggleGuide: noop, + toggleHideAmounts: noop, + toggleSidebar: noop, + updateSessionConfig: noop, +}); + +const createWrapper = ({ + initialEntry = '/settings', + isTesting = false, + onLocationChange, +}: { + initialEntry?: string; + isTesting?: boolean; + onLocationChange?: (search: string) => void; +} = {}) => { + const LocationObserver = () => { + const location = useLocation(); + onLocationChange?.(location.search); + return null; + }; + + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + ); +}; + +const renderUseSettingsSearch = ({ + devices = {}, + hasAccounts = true, + initialEntry, + isTesting, + onLocationChange, +}: { + devices?: TDevices; + hasAccounts?: boolean; + initialEntry?: string; + isTesting?: boolean; + onLocationChange?: (search: string) => void; +} = {}) => renderHook( + () => useSettingsSearch({ + devices, + hasAccounts, + }), + { + wrapper: createWrapper({ + initialEntry, + isTesting, + onLocationChange, + }), + }, +); + +describe('useSettingsSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initializes from the settings search query param', () => { + const { result } = renderUseSettingsSearch({ + initialEntry: `/settings?${SETTINGS_SEARCH_QUERY_PARAM}=DEFAULT%20Curr`, + }); + + expect(result.current.searchTerm).toBe('DEFAULT Curr'); + expect(result.current.showSearchResults).toBe(true); + expect(result.current.searchResults.map(item => item.title)).toEqual(['Default currency']); + }); + + it('updates search results and stores the query in the URL', async () => { + let currentSearch = ''; + const { result } = renderUseSettingsSearch({ + onLocationChange: search => { + currentSearch = search; + }, + }); + + act(() => { + result.current.updateSearchTerm('currency'); + }); + + await waitFor(() => { + expect(currentSearch).toBe(`?${SETTINGS_SEARCH_QUERY_PARAM}=currency`); + }); + expect(result.current.showSearchResults).toBe(true); + expect(result.current.searchResults.map(item => item.title)).toEqual(['Default currency']); + }); + + it('removes the query param when the search term is cleared', async () => { + let currentSearch = ''; + const { result } = renderUseSettingsSearch({ + initialEntry: `/settings?${SETTINGS_SEARCH_QUERY_PARAM}=language`, + onLocationChange: search => { + currentSearch = search; + }, + }); + + act(() => { + result.current.updateSearchTerm(''); + }); + + await waitFor(() => { + expect(currentSearch).toBe(''); + }); + expect(result.current.searchTerm).toBe(''); + expect(result.current.showSearchResults).toBe(false); + }); + + it('uses app context when building search items', () => { + const { result } = renderUseSettingsSearch({ + isTesting: true, + initialEntry: `/settings?${SETTINGS_SEARCH_QUERY_PARAM}=test%20wallet`, + }); + + expect(result.current.searchResults.map(item => item.title)).toEqual(['Test wallet']); + }); +}); diff --git a/frontends/web/src/routes/settings/use-settings-search.ts b/frontends/web/src/routes/settings/use-settings-search.ts new file mode 100644 index 0000000000..87a1d6c748 --- /dev/null +++ b/frontends/web/src/routes/settings/use-settings-search.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useContext, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { getDeviceInfo } from '@/api/bitbox02'; +import type { TDevices } from '@/api/devices'; +import { AppContext } from '@/contexts/AppContext'; +import { useLoad } from '@/hooks/api'; +import { + SETTINGS_SEARCH_QUERY_PARAM, + filterSettingsSearchItems, + getSettingsSearchItems, +} from './settings-search'; + +type TUseSettingsSearchArgs = { + devices: TDevices; + hasAccounts: boolean; +}; + +export const useSettingsSearch = ({ + devices, + hasAccounts, +}: TUseSettingsSearchArgs) => { + const { t } = useTranslation(); + const { isTesting } = useContext(AppContext); + const [searchParams, setSearchParams] = useSearchParams(); + const searchTermFromParams = searchParams.get(SETTINGS_SEARCH_QUERY_PARAM) || ''; + const [searchTerm, setSearchTerm] = useState(searchTermFromParams); + const deviceId = Object.keys(devices)[0]; + const device = deviceId ? devices[deviceId] : undefined; + const bb02DeviceId = device === 'bitbox02' ? deviceId : undefined; + const deviceInfoResult = useLoad( + bb02DeviceId ? () => getDeviceInfo(bb02DeviceId) : null, + [bb02DeviceId], + ); + const deviceInfo = deviceInfoResult?.success ? deviceInfoResult.deviceInfo : undefined; + const searchItems = useMemo(() => getSettingsSearchItems({ + deviceInfo, + devices, + hasAccounts, + isTesting, + t, + }), [deviceInfo, devices, hasAccounts, isTesting, t]); + const searchResults = useMemo( + () => filterSettingsSearchItems(searchItems, searchTerm), + [searchItems, searchTerm], + ); + const showSearchResults = !!searchTerm.trim(); + + useEffect(() => { + setSearchTerm(searchTermFromParams); + }, [searchTermFromParams]); + + const updateSearchTerm = (value: string) => { + setSearchTerm(value); + const nextSearchParams = new URLSearchParams(searchParams); + if (value.toLowerCase().trim()) { + nextSearchParams.set(SETTINGS_SEARCH_QUERY_PARAM, value); + } else { + nextSearchParams.delete(SETTINGS_SEARCH_QUERY_PARAM); + } + setSearchParams(nextSearchParams, { replace: true }); + }; + + return { + searchResults, + searchTerm, + showSearchResults, + updateSearchTerm, + }; +};