diff --git a/static/app/components/search/sources/formSource.tsx b/static/app/components/search/sources/formSource.tsx index 95f493605811..6b0629c6e23e 100644 --- a/static/app/components/search/sources/formSource.tsx +++ b/static/app/components/search/sources/formSource.tsx @@ -9,7 +9,7 @@ import {createFuzzySearch} from 'sentry/utils/fuzzySearch'; import type {ChildProps, Result} from './types'; import {makeResolvedTs, strGetFn} from './utils'; -type FormSearchField = { +export type FormSearchField = { description: React.ReactNode; field: {name: string}; route: string; @@ -102,7 +102,7 @@ function getOldFormFields(): FormSearchField[] { }); } -function getSearchMap() { +export function getSearchMap() { if (ALL_FORM_FIELDS_CACHED !== null) { return ALL_FORM_FIELDS_CACHED; } diff --git a/static/app/views/settings/components/settingsWrapper.tsx b/static/app/views/settings/components/settingsWrapper.tsx index 65e8eaf8da09..bed6e2d1894d 100644 --- a/static/app/views/settings/components/settingsWrapper.tsx +++ b/static/app/views/settings/components/settingsWrapper.tsx @@ -9,6 +9,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useScrollToTop} from 'sentry/utils/useScrollToTop'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; import {BreadcrumbProvider} from 'sentry/views/settings/components/settingsBreadcrumb/context'; +import {SettingsCommandPaletteActions} from 'sentry/views/settings/settingsCommandPaletteActions'; function scrollDisable(newLocation: Location, prevLocation: Location) { return newLocation.pathname === prevLocation.pathname; @@ -24,6 +25,7 @@ export function SettingsWrapper() { + diff --git a/static/app/views/settings/settingsCommandPaletteActions.tsx b/static/app/views/settings/settingsCommandPaletteActions.tsx new file mode 100644 index 000000000000..3da8afbfced1 --- /dev/null +++ b/static/app/views/settings/settingsCommandPaletteActions.tsx @@ -0,0 +1,158 @@ +import {useMemo, type ReactNode} from 'react'; + +import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import type {FormSearchField} from 'sentry/components/search/sources/formSource'; +import {getSearchMap} from 'sentry/components/search/sources/formSource'; +import {IconLock, IconMail, IconSettings, IconSubscribed, IconUser} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {replaceRouterParams} from 'sentry/utils/replaceRouterParams'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; + +const ROUTE_ICONS: Record = { + '/settings/account/details/': , + '/settings/account/security/': , + '/settings/account/notifications/': , + '/settings/account/emails/': , + '/settings/:orgId/': , + '/settings/:orgId/security-and-privacy/': , +}; + +function normalizeRouteForLookup(route: string): string { + if (route === '/settings/organization/') { + return '/settings/:orgId/'; + } + return route; +} + +function resolveRoutePath(route: string, orgSlug: string): string { + return replaceRouterParams(normalizeRouteForLookup(route), {orgId: orgSlug}); +} + +function titleFromRoute(route: string): string { + const segment = route + .replace(/^\/settings\//, '') + .replace(/^:orgId\//, '') + .replace(/^account\//, '') + .split('/')[0]; + + if (!segment) return 'Settings'; + + return segment + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function isSettingsRoute(route: string): boolean { + if (!route.startsWith('/settings/')) return false; + if (route.includes(':projectId')) return false; + if (route.includes(':teamId')) return false; + if (route.includes(':appId')) return false; + return true; +} + +type SettingsFieldEntry = { + display: {label: string}; + key: string; + keywords: string[]; + to: {hash: string; pathname: string}; +}; + +type SettingsFieldSection = { + fields: SettingsFieldEntry[]; + key: string; + title: string; + icon?: ReactNode; +}; + +function getSettingsFieldSections(orgSlug: string): SettingsFieldSection[] { + const allFields = getSearchMap(); + + const routeTitleMap = new Map(); + for (const section of getUserOrgNavigationConfiguration()) { + for (const item of section.items) { + routeTitleMap.set(item.path, item.title); + } + } + + const groups = new Map>(); + for (const field of allFields) { + if (!isSettingsRoute(field.route)) continue; + if (typeof field.title !== 'string' || !field.title) continue; + + const normalizedRoute = normalizeRouteForLookup(field.route); + let routeFields = groups.get(normalizedRoute); + if (!routeFields) { + routeFields = new Map(); + groups.set(normalizedRoute, routeFields); + } + routeFields.set(field.field.name, field); + } + + return Array.from(groups.entries()) + .map(([route, fieldMap]): SettingsFieldSection => { + const title = routeTitleMap.get(route) ?? titleFromRoute(route); + const resolvedPath = resolveRoutePath(route, orgSlug); + + return { + key: route, + title, + icon: ROUTE_ICONS[route], + fields: Array.from(fieldMap.values()) + .filter( + (f): f is FormSearchField & {title: string} => + typeof f.title === 'string' && f.title.length > 0 + ) + .map(f => ({ + key: `${route}#${f.field.name}`, + display: { + label: f.title, + }, + keywords: ['settings', title, f.field.name], + to: { + pathname: resolvedPath, + hash: `#${encodeURIComponent(f.field.name)}`, + }, + })) + .sort((a, b) => a.display.label.localeCompare(b.display.label)), + }; + }) + .filter(section => section.fields.length > 0) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +export function SettingsCommandPaletteActions() { + const organization = useOrganization({allowNull: true}); + const sections = useMemo( + () => (organization ? getSettingsFieldSections(organization.slug) : []), + [organization] + ); + + if (sections.length === 0) { + return null; + } + + return ( + + }}> + {sections.map(section => ( + + {section.fields.map(field => ( + + ))} + + ))} + + + ); +}