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 000000000000..2097dab3fd18 --- /dev/null +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.spec.tsx @@ -0,0 +1,94 @@ +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'; + +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: [ + PluginFixture({enabled: true, id: 'github', isDeprecated: false, name: 'GitHub'}), + PluginFixture({ + enabled: true, + id: 'legacy', + isDeprecated: true, + name: 'Legacy Plugin', + }), + ], + }); + + const sections = getProjectSettingsCommandPaletteSections({organization, project}); + + expect(sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Project Settings', + items: expect.arrayContaining([ + expect.objectContaining({ + 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/', + }), + ]), + }), + expect.objectContaining({ + label: 'SDK setup', + }), + expect.objectContaining({ + display: expect.objectContaining({label: 'Legacy Integrations'}), + to: '/settings/acme/projects/frontend/plugins/', + }), + ]), + }), + ]) + ); + + expect(sections).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Project Settings', + items: expect.arrayContaining([ + expect.objectContaining({ + label: 'Processing', + items: expect.arrayContaining([ + expect.objectContaining({ + to: '/settings/acme/projects/frontend/replays/', + }), + ]), + }), + ]), + }), + ]) + ); + + expect(sections).not.toEqual( + expect.arrayContaining([ + 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 000000000000..e866d6c6de92 --- /dev/null +++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx @@ -0,0 +1,176 @@ +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 {IconCode, IconProject, IconStack} from 'sentry/icons'; +import {t} from 'sentry/locale'; +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 ProjectSettingsCommandPaletteEntry = { + display: { + label: string; + }; + keywords: string[]; + to: string; +}; + +type ProjectSettingsCommandPaletteGroup = { + items: Array<{ + display: { + label: string; + }; + keywords: string[]; + to: string; + }>; + label: string; + icon?: ReactNode; +}; + +type ProjectSettingsCommandPaletteSection = { + items: Array; + label: string; + icon?: ReactNode; +}; + +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, + }; + const groupedSectionIds = new Set([ + 'settings-project', + 'settings-processing', + 'settings-sdk', + 'settings-legacy-integrations', + ]); + + const sections = getNavigationConfiguration({ + debugFilesNeedsReview: false, + organization, + project, + }) + .map(section => { + const label = + section.id === 'settings-project' + ? t('General') + : section.id === 'settings-sdk' + ? t('SDK setup') + : section.name; + + return { + id: section.id, + icon: groupedSectionIds.has(section.id) ? ( + section.id === 'settings-project' ? ( + + ) : section.id === 'settings-processing' ? ( + + ) : section.id === 'settings-sdk' ? ( + + ) : 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 => groupedSectionIds.has(section.id)); + const ungroupedSections = sections.filter( + section => !groupedSectionIds.has(section.id) + ); + + if (groupedSections.length === 0) { + return ungroupedSections; + } + + return [ + { + icon: , + label: t('Project Settings'), + items: groupedSections.map(section => + section.id === 'settings-legacy-integrations' && section.items.length > 0 + ? section.items[0]! + : section + ), + }, + ...ungroupedSections, + ]; +} + +export function ProjectSettingsCommandPaletteActions({ + organization, + project, +}: { + organization: Organization; + project: Project; +}) { + const sections = getProjectSettingsCommandPaletteSections({organization, project}); + + return ( + + {sections.map(section => { + return ( + + + {section.items.map(item => { + if ('items' in item) { + return ( + + {item.items.map(action => ( + + ))} + + ); + } + + return ; + })} + + + ); + })} + + ); +} diff --git a/static/app/views/settings/project/projectSettingsLayout.tsx b/static/app/views/settings/project/projectSettingsLayout.tsx index 914625557af0..45f092cf5939 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}) => ( + + )} );