Add searchable settings selector to AppConfigModal and improve nav behavior (#5873)

This commit is contained in:
Anthony Stirling
2026-03-06 11:11:17 +00:00
committed by GitHub
parent 456106195e
commit a57e336675
4 changed files with 264 additions and 8 deletions

View File

@@ -5902,6 +5902,9 @@ title = "Policies & Privacy"
[settings.preferences]
title = "Preferences"
[settings.search]
placeholder = "Search settings pages..."
[settings.security]
description = "Update your password to keep your account secure."
title = "Security"

View File

@@ -163,6 +163,16 @@
padding-top: 1rem;
}
.settings-search-select {
min-width: 10rem;
}
.settings-search-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
@@ -214,4 +224,4 @@
.settings-section-content {
padding-bottom: 4rem;
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -10,6 +10,7 @@ import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
import { SettingsSearchBar } from '@app/components/shared/config/SettingsSearchBar';
interface AppConfigModalProps {
opened: boolean;
@@ -24,6 +25,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
const { confirmIfDirty } = useUnsavedChanges();
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
@@ -46,6 +48,13 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}
}, [location.pathname, opened, navigate]);
useEffect(() => {
if (opened) {
// Keep search closed initially by moving autofocus away from the searchable Select input.
closeButtonRef.current?.focus();
}
}, [opened]);
// Handle custom events for backwards compatibility
useEffect(() => {
const handler = (ev: Event) => {
@@ -108,7 +117,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const handleNavigation = useCallback(async (key: NavKey) => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
setActive(key);
navigate(`/settings/${key}`);
}, [confirmIfDirty, navigate]);
@@ -158,12 +167,16 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const navItemContent = (
<div
key={item.key}
onClick={() => handleNavigation(item.key)}
onClick={() => {
if (!isDisabled) {
handleNavigation(item.key);
}
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.6 : 1,
cursor: 'pointer',
cursor: isDisabled ? 'not-allowed' : 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>
@@ -218,9 +231,22 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={handleClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
<Group gap="xs" wrap="nowrap">
<SettingsSearchBar
configNavSections={configNavSections}
onNavigate={handleNavigation}
isMobile={isMobile}
/>
<ActionIcon
ref={closeButtonRef}
variant="subtle"
onClick={handleClose}
aria-label="Close"
data-autofocus
>
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</div>
<div className="modal-body">
{activeComponent}

View File

@@ -0,0 +1,217 @@
import React, { useMemo, useState, useCallback } from 'react';
import { Select, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import type { ConfigNavSection, ConfigNavItem } from '@core/components/shared/config/configNavSections';
interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[];
onNavigate: (key: NavKey) => Promise<void>;
isMobile: boolean;
}
interface SettingsSearchOption {
value: NavKey;
label: string;
sectionTitle: string;
destinationPath: string;
searchableContent: string[];
matchedContext?: string;
}
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
general: ['settings.general'],
hotkeys: ['settings.hotkeys'],
account: ['account'],
people: ['settings.workspace'],
teams: ['settings.workspace', 'settings.team'],
'api-keys': ['settings.developer'],
connectionMode: ['settings.connection'],
planBilling: ['settings.planBilling'],
adminGeneral: ['admin.settings.general'],
adminFeatures: ['admin.settings.features'],
adminEndpoints: ['admin.settings.endpoints'],
adminDatabase: ['admin.settings.database'],
adminAdvanced: ['admin.settings.advanced'],
adminSecurity: ['admin.settings.security'],
adminConnections: [
'admin.settings.connections',
'admin.settings.mail',
'admin.settings.security',
'admin.settings.telegram',
'admin.settings.premium',
'admin.settings.general',
'settings.securityAuth',
'settings.connection',
],
adminPlan: ['settings.planBilling', 'admin.settings.premium', 'settings.licensingAnalytics'],
adminAudit: ['settings.licensingAnalytics'],
adminUsage: ['settings.licensingAnalytics'],
adminLegal: ['admin.settings.legal'],
adminPrivacy: ['admin.settings.privacy'],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith('admin')) {
const adminSuffix = key.replace(/^admin/, '');
const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === 'object') {
return Object.values(value as Record<string, unknown>).flatMap(flattenTranslationStrings);
}
return [];
};
const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? '…' : ''}${snippet}${end < text.length ? '…' : ''}`;
}
return `${start > 0 ? '…' : ''}${snippet.slice(0, maxLength)}${end < text.length ? '…' : ''}`;
};
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
configNavSections,
onNavigate,
isMobile,
}) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
// Build a global index from every accessible settings tab in the modal navigation.
// This does not render section components, so API calls still happen only when a tab is opened.
const searchableSections = useMemo<SettingsSearchOption[]>(() => {
return configNavSections.flatMap((section) =>
section.items
.filter((item: ConfigNavItem) => !item.disabled)
.map((item: ConfigNavItem) => {
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any))
);
const searchableContent = Array.from(
new Set([
item.label,
section.title,
`/settings/${item.key}`,
...translationContent,
])
);
return {
value: item.key,
label: item.label,
sectionTitle: section.title,
destinationPath: `/settings/${item.key}`,
searchableContent,
};
})
);
}, [configNavSections, t]);
const filteredSearchableSections = useMemo<SettingsSearchOption[]>(() => {
const query = searchValue.trim();
if (!query) {
return searchableSections;
}
const normalizedQuery = query.toLocaleLowerCase();
return searchableSections.reduce<SettingsSearchOption[]>((accumulator, option) => {
const matchedEntry = option.searchableContent.find((entry) =>
entry.toLocaleLowerCase().includes(normalizedQuery)
);
if (!matchedEntry) {
return accumulator;
}
accumulator.push({
...option,
matchedContext: buildMatchSnippet(matchedEntry, query),
});
return accumulator;
}, []);
}, [searchValue, searchableSections]);
const handleSearchNavigation = useCallback(async (value: string | null) => {
if (!value) return;
if (!VALID_NAV_KEYS.includes(value as NavKey)) return;
await onNavigate(value as NavKey);
setSearchValue('');
}, [onNavigate]);
return (
<Select
className="settings-search-select"
data={filteredSearchableSections}
value={null}
searchValue={searchValue}
onSearchChange={setSearchValue}
onChange={handleSearchNavigation}
placeholder={t('settings.search.placeholder', 'Search settings pages...')}
leftSection={<LocalIcon icon="search-rounded" width={16} height={16} />}
aria-label={t('navbar.search', 'Search')}
nothingFoundMessage={t('search.noResults', 'No results found')}
searchable
clearable={false}
w={isMobile ? 170 : 320}
filter={({ options }) => options}
renderOption={({ option }) => {
const searchOption = option as unknown as SettingsSearchOption;
return (
<div className="settings-search-option">
<Text size="sm" fw={600}>{searchOption.label}</Text>
<Text size="xs" c="dimmed">
{searchOption.sectionTitle} · {searchOption.matchedContext || searchOption.destinationPath}
</Text>
</div>
);
}}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
);
};