From 211841043196a74de26ce5b3562bf95d51cb89d3 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Tue, 14 Apr 2026 14:26:04 +0200 Subject: [PATCH 1/2] ref(cmdk) add project settings search --- ...jectSettingsCommandPaletteActions.spec.tsx | 72 +++++++++++++ .../projectSettingsCommandPaletteActions.tsx | 100 ++++++++++++++++++ .../project/projectSettingsLayout.tsx | 20 +++- 3 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx create mode 100644 static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx diff --git a/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx b/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx new file mode 100644 index 00000000000000..d066647ff46410 --- /dev/null +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx @@ -0,0 +1,72 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {getProjectSettingsCommandPaletteSections} from 'sentry/views/settings/project/projectSettingsCommandPaletteActions'; + +describe('ProjectSettingsCommandPaletteActions', () => { + it('returns current project settings sections with visibility rules applied', () => { + const organization = OrganizationFixture({ + access: ['project:write'], + features: ['performance-view'], + slug: 'acme', + }); + const project = ProjectFixture({ + slug: 'frontend', + plugins: [ + {enabled: true, id: 'github', isDeprecated: false, name: 'GitHub'}, + {enabled: true, id: 'legacy', isDeprecated: true, name: 'Legacy Plugin'}, + ], + }); + + const sections = getProjectSettingsCommandPaletteSections({organization, project}); + + expect(sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Project', + items: expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({label: 'General Settings'}), + to: '/settings/acme/projects/frontend/', + }), + ]), + }), + expect.objectContaining({ + label: 'Processing', + items: expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({label: 'Performance'}), + to: '/settings/acme/projects/frontend/performance/', + }), + ]), + }), + expect.objectContaining({ + label: 'Legacy Integrations', + items: expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({label: 'GitHub'}), + to: '/settings/acme/projects/frontend/plugins/github/', + }), + ]), + }), + ]) + ); + + expect(sections).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({to: '/settings/acme/projects/frontend/replays/'}), + ]), + }), + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + to: '/settings/acme/projects/frontend/plugins/legacy/', + }), + ]), + }), + ]) + ); + }); +}); diff --git a/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx new file mode 100644 index 00000000000000..c90a97b46c9ccb --- /dev/null +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx @@ -0,0 +1,100 @@ +import {Fragment} from 'react'; +import type {ReactNode} from 'react'; + +import {ProjectAvatar} from '@sentry/scraps/avatar'; + +import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import {replaceRouterParams} from 'sentry/utils/replaceRouterParams'; +import {getNavigationConfiguration} from 'sentry/views/settings/project/navigationConfiguration'; +import type {NavigationGroupProps, NavigationItem} from 'sentry/views/settings/types'; + +type ProjectSettingsCommandPaletteAction = { + display: { + label: string; + }; + keywords: string[]; + to: string; +}; + +type ProjectSettingsCommandPaletteSection = { + icon: ReactNode; + items: ProjectSettingsCommandPaletteAction[]; + label: string; +}; + +function shouldShowItem( + item: NavigationItem, + context: Omit, + section: {id: string; items: NavigationItem[]; name: string} +) { + if (typeof item.show === 'function') { + return item.show({...context, ...section}); + } + + return item.show !== false; +} + +export function getProjectSettingsCommandPaletteSections({ + organization, + project, +}: { + organization: Organization; + project: Project; +}): ProjectSettingsCommandPaletteSection[] { + const context = { + access: new Set(organization.access), + features: new Set(organization.features), + organization, + project, + }; + + return getNavigationConfiguration({ + debugFilesNeedsReview: false, + organization, + project, + }) + .map(section => ({ + icon: , + label: section.name, + items: section.items + .filter(item => shouldShowItem(item, context, section)) + .map(item => ({ + display: { + label: item.title, + }, + keywords: [section.name, 'project settings', 'settings'], + to: replaceRouterParams(item.path, { + orgId: organization.slug, + projectId: project.slug, + }), + })), + })) + .filter(section => section.items.length > 0); +} + +export function ProjectSettingsCommandPaletteActions({ + organization, + project, +}: { + organization: Organization; + project: Project; +}) { + const sections = getProjectSettingsCommandPaletteSections({organization, project}); + + return ( + + {sections.map(section => ( + + + {section.items.map(item => ( + + ))} + + + ))} + + ); +} diff --git a/static/app/views/settings/project/projectSettingsLayout.tsx b/static/app/views/settings/project/projectSettingsLayout.tsx index 914625557af0ae..45f092cf593918 100644 --- a/static/app/views/settings/project/projectSettingsLayout.tsx +++ b/static/app/views/settings/project/projectSettingsLayout.tsx @@ -9,13 +9,16 @@ import {AnalyticsArea} from 'sentry/components/analyticsArea'; import {EmptyMessage} from 'sentry/components/emptyMessage'; import {IconProject} from 'sentry/icons'; import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import ProjectContext from 'sentry/views/projects/projectContext'; import {SettingsLayout} from 'sentry/views/settings/components/settingsLayout'; +import {ProjectSettingsCommandPaletteActions} from 'sentry/views/settings/project/projectSettingsCommandPaletteActions'; type ProjectSettingsOutletContext = { project: Project; @@ -29,7 +32,13 @@ export function useProjectSettingsOutlet() { return useOutletContext(); } -function InnerProjectSettingsLayout({project}: {project: Project}) { +function InnerProjectSettingsLayout({ + organization, + project, +}: { + organization: Organization; + project: Project; +}) { // set analytics params for route based analytics useRouteAnalyticsParams({ project_id: project.id, @@ -38,12 +47,17 @@ function InnerProjectSettingsLayout({project}: {project: Project}) { return ( + ); } export default function ProjectSettingsLayout() { + const organization = useOrganization(); const params = useParams<{projectId: string}>(); const location = useLocation(); const navigate = useNavigate(); @@ -84,7 +98,9 @@ export default function ProjectSettingsLayout() { return ( - {({project}) => } + {({project}) => ( + + )} ); From 0944e8c5e3227d016e6b22e3096429eecabae602 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Tue, 14 Apr 2026 14:41:33 +0200 Subject: [PATCH 2/2] feat(cmdk): Group project settings actions Group the project settings command palette entries under a single parent so browse mode stays more compact and easier to scan. Add section-specific icons for the main project settings groups, keep legacy integrations as a direct action, and cover the new structure in the existing command palette test. Co-Authored-By: OpenAI Codex --- ...jectSettingsCommandPaletteActions.spec.tsx | 57 +++++--- .../projectSettingsCommandPaletteActions.tsx | 134 ++++++++++++++---- 2 files changed, 142 insertions(+), 49 deletions(-) diff --git a/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx b/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx index d066647ff46410..22a22a3fbf4a14 100644 --- a/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx @@ -1,4 +1,5 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PluginFixture} from 'sentry-fixture/plugin'; import {ProjectFixture} from 'sentry-fixture/project'; import {getProjectSettingsCommandPaletteSections} from 'sentry/views/settings/project/projectSettingsCommandPaletteActions'; @@ -13,8 +14,13 @@ describe('ProjectSettingsCommandPaletteActions', () => { const project = ProjectFixture({ slug: 'frontend', plugins: [ - {enabled: true, id: 'github', isDeprecated: false, name: 'GitHub'}, - {enabled: true, id: 'legacy', isDeprecated: true, name: 'Legacy Plugin'}, + PluginFixture({enabled: true, id: 'github', isDeprecated: false, name: 'GitHub'}), + PluginFixture({ + enabled: true, + id: 'legacy', + isDeprecated: true, + name: 'Legacy Plugin', + }), ], }); @@ -23,29 +29,32 @@ describe('ProjectSettingsCommandPaletteActions', () => { expect(sections).toEqual( expect.arrayContaining([ expect.objectContaining({ - label: 'Project', + label: 'Project Settings', items: expect.arrayContaining([ expect.objectContaining({ - display: expect.objectContaining({label: 'General Settings'}), - to: '/settings/acme/projects/frontend/', + label: 'General', + items: expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({label: 'General Settings'}), + to: '/settings/acme/projects/frontend/', + }), + ]), }), - ]), - }), - expect.objectContaining({ - label: 'Processing', - items: expect.arrayContaining([ expect.objectContaining({ - display: expect.objectContaining({label: 'Performance'}), - to: '/settings/acme/projects/frontend/performance/', + label: 'Processing', + items: expect.arrayContaining([ + expect.objectContaining({ + display: expect.objectContaining({label: 'Performance'}), + to: '/settings/acme/projects/frontend/performance/', + }), + ]), + }), + expect.objectContaining({ + label: 'SDK setup', }), - ]), - }), - expect.objectContaining({ - label: 'Legacy Integrations', - items: expect.arrayContaining([ expect.objectContaining({ - display: expect.objectContaining({label: 'GitHub'}), - to: '/settings/acme/projects/frontend/plugins/github/', + display: expect.objectContaining({label: 'Legacy Integrations'}), + to: '/settings/acme/projects/frontend/plugins/', }), ]), }), @@ -55,8 +64,16 @@ describe('ProjectSettingsCommandPaletteActions', () => { expect(sections).not.toEqual( expect.arrayContaining([ expect.objectContaining({ + label: 'Project Settings', items: expect.arrayContaining([ - expect.objectContaining({to: '/settings/acme/projects/frontend/replays/'}), + expect.objectContaining({ + label: 'Processing', + items: expect.arrayContaining([ + expect.objectContaining({ + to: '/settings/acme/projects/frontend/replays/', + }), + ]), + }), ]), }), expect.objectContaining({ diff --git a/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx index c90a97b46c9ccb..a866ad0cf6fd4f 100644 --- a/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx @@ -5,13 +5,14 @@ import {ProjectAvatar} from '@sentry/scraps/avatar'; import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import {IconCode, IconProject, IconStack} from 'sentry/icons'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {replaceRouterParams} from 'sentry/utils/replaceRouterParams'; import {getNavigationConfiguration} from 'sentry/views/settings/project/navigationConfiguration'; import type {NavigationGroupProps, NavigationItem} from 'sentry/views/settings/types'; -type ProjectSettingsCommandPaletteAction = { +type ProjectSettingsCommandPaletteEntry = { display: { label: string; }; @@ -19,10 +20,22 @@ type ProjectSettingsCommandPaletteAction = { to: string; }; +type ProjectSettingsCommandPaletteGroup = { + items: Array<{ + display: { + label: string; + }; + keywords: string[]; + to: string; + }>; + label: string; + icon?: ReactNode; +}; + type ProjectSettingsCommandPaletteSection = { - icon: ReactNode; - items: ProjectSettingsCommandPaletteAction[]; + items: Array; label: string; + icon?: ReactNode; }; function shouldShowItem( @@ -50,29 +63,77 @@ export function getProjectSettingsCommandPaletteSections({ organization, project, }; + const groupedSectionLabels = new Set([ + 'General', + 'Processing', + 'SDK setup', + 'Legacy Integrations', + ]); - return getNavigationConfiguration({ + const sections = getNavigationConfiguration({ debugFilesNeedsReview: false, organization, project, }) - .map(section => ({ - icon: , - label: section.name, - items: section.items - .filter(item => shouldShowItem(item, context, section)) - .map(item => ({ - display: { - label: item.title, - }, - keywords: [section.name, 'project settings', 'settings'], - to: replaceRouterParams(item.path, { - orgId: organization.slug, - projectId: project.slug, - }), - })), - })) + .map(section => { + const label = + section.name === 'Project' + ? 'General' + : section.name === 'SDK Setup' + ? 'SDK setup' + : section.name; + + return { + icon: groupedSectionLabels.has(label) ? ( + label === 'General' ? ( + + ) : label === 'Processing' ? ( + + ) : label === 'SDK setup' ? ( + + ) : undefined + ) : ( + + ), + label, + items: section.items + .filter(item => shouldShowItem(item, context, section)) + .map(item => ({ + display: { + label: item.title, + }, + keywords: [section.name, 'project settings', 'settings'], + to: replaceRouterParams(item.path, { + orgId: organization.slug, + projectId: project.slug, + }), + })), + }; + }) .filter(section => section.items.length > 0); + const groupedSections = sections.filter(section => + groupedSectionLabels.has(section.label) + ); + const ungroupedSections = sections.filter( + section => !groupedSectionLabels.has(section.label) + ); + + if (groupedSections.length === 0) { + return ungroupedSections; + } + + return [ + { + icon: , + label: 'Project Settings', + items: groupedSections.map(section => + section.label === 'Legacy Integrations' && section.items.length > 0 + ? section.items[0]! + : section + ), + }, + ...ungroupedSections, + ]; } export function ProjectSettingsCommandPaletteActions({ @@ -86,15 +147,30 @@ export function ProjectSettingsCommandPaletteActions({ return ( - {sections.map(section => ( - - - {section.items.map(item => ( - - ))} - - - ))} + {sections.map(section => { + return ( + + + {section.items.map(item => { + if ('items' in item) { + return ( + + {item.items.map(action => ( + + ))} + + ); + } + + return ; + })} + + + ); + })} ); }