Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
to: '/settings/acme/projects/frontend/plugins/legacy/',
}),
]),
}),
])
);
});
});
Original file line number Diff line number Diff line change
@@ -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 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<ProjectSettingsCommandPaletteEntry | ProjectSettingsCommandPaletteGroup>;
label: string;
icon?: ReactNode;
};

function shouldShowItem(
item: NavigationItem,
context: Omit<NavigationGroupProps, 'items' | 'name' | 'id'>,
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 groupedSectionLabels = new Set([
'General',
'Processing',
'SDK setup',
'Legacy Integrations',
]);

const sections = getNavigationConfiguration({
debugFilesNeedsReview: false,
organization,
project,
})
.map(section => {
const label =
section.name === 'Project'
? 'General'
: section.name === 'SDK Setup'
? 'SDK setup'
: section.name;

return {
icon: groupedSectionLabels.has(label) ? (
label === 'General' ? (
<IconProject />
) : label === 'Processing' ? (
<IconStack />
) : label === 'SDK setup' ? (
<IconCode />
) : undefined
) : (
<ProjectAvatar project={project} size={16} />
),
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: <ProjectAvatar project={project} size={16} />,
label: 'Project Settings',
items: groupedSections.map(section =>
section.label === '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 (
<Fragment>
{sections.map(section => {
return (
<CommandPaletteSlot key={section.label} name="page">
<CMDKAction display={{label: section.label, icon: section.icon}}>
{section.items.map(item => {
if ('items' in item) {
return (
<CMDKAction
key={item.label}
display={{label: item.label, icon: item.icon}}
>
{item.items.map(action => (
<CMDKAction key={action.to} {...action} />
))}
</CMDKAction>
);
}

return <CMDKAction key={item.to} {...item} />;
})}
</CMDKAction>
</CommandPaletteSlot>
);
})}
</Fragment>
);
}
20 changes: 18 additions & 2 deletions static/app/views/settings/project/projectSettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +32,13 @@ export function useProjectSettingsOutlet() {
return useOutletContext<ProjectSettingsOutletContext>();
}

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,
Expand All @@ -38,12 +47,17 @@ function InnerProjectSettingsLayout({project}: {project: Project}) {

return (
<SettingsLayout>
<ProjectSettingsCommandPaletteActions
organization={organization}
project={project}
/>
<ProjectSettingsOutlet project={project} />
</SettingsLayout>
);
}

export default function ProjectSettingsLayout() {
const organization = useOrganization();
const params = useParams<{projectId: string}>();
const location = useLocation();
const navigate = useNavigate();
Expand Down Expand Up @@ -84,7 +98,9 @@ export default function ProjectSettingsLayout() {
return (
<AnalyticsArea name="project">
<ProjectContext projectSlug={params.projectId}>
{({project}) => <InnerProjectSettingsLayout project={project} />}
{({project}) => (
<InnerProjectSettingsLayout organization={organization} project={project} />
)}
</ProjectContext>
</AnalyticsArea>
);
Expand Down
Loading