Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions static/app/components/search/sources/formSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,7 +102,7 @@ function getOldFormFields(): FormSearchField[] {
});
}

function getSearchMap() {
export function getSearchMap() {
if (ALL_FORM_FIELDS_CACHED !== null) {
return ALL_FORM_FIELDS_CACHED;
}
Expand Down
2 changes: 2 additions & 0 deletions static/app/views/settings/components/settingsWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@ export function SettingsWrapper() {
<AnalyticsArea name="settings">
<StyledFlex flex="1" background={hasPageFrame ? 'primary' : undefined}>
<BreadcrumbProvider>
<SettingsCommandPaletteActions />
<Outlet />
</BreadcrumbProvider>
</StyledFlex>
Expand Down
158 changes: 158 additions & 0 deletions static/app/views/settings/settingsCommandPaletteActions.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ReactNode> = {
'/settings/account/details/': <IconUser />,
'/settings/account/security/': <IconLock />,
'/settings/account/notifications/': <IconSubscribed />,
'/settings/account/emails/': <IconMail />,
'/settings/:orgId/': <IconSettings />,
'/settings/:orgId/security-and-privacy/': <IconLock />,
};

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<string, string>();
for (const section of getUserOrgNavigationConfiguration()) {
for (const item of section.items) {
routeTitleMap.set(item.path, item.title);
}
}

const groups = new Map<string, Map<string, FormSearchField>>();
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 (
<CommandPaletteSlot name="page">
<CMDKAction display={{label: t('Settings Fields'), icon: <IconSettings />}}>
{sections.map(section => (
<CMDKAction
key={section.key}
display={{label: section.title, icon: section.icon}}
>
{section.fields.map(field => (
<CMDKAction
key={field.key}
display={field.display}
keywords={field.keywords}
to={field.to}
/>
))}
</CMDKAction>
))}
</CMDKAction>
</CommandPaletteSlot>
);
}
Loading