diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.spec.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.spec.tsx
index 6dd69a154c8a..b2588b0eed29 100644
--- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.spec.tsx
+++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.spec.tsx
@@ -526,42 +526,104 @@ describe('GlobalCommandPaletteActions - search recall', () => {
});
});
-describe('GlobalCommandPaletteActions - Seer XRay Mode gating', () => {
- function mockOrgApis(organization: ReturnType) {
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/group-search-views/starred/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/dashboards/starred/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/dashboards/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/explore/all-queries/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/users/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/members/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/teams/`,
- body: [],
- });
- MockApiClient.addMockResponse({
- url: `/organizations/${organization.slug}/projects/`,
- body: [],
- });
+function mockOrgApis(organization: ReturnType) {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/group-search-views/starred/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/dashboards/starred/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/dashboards/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/explore/all-queries/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/users/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/members/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/teams/`,
+ body: [],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/projects/`,
+ body: [],
+ });
+}
+
+describe('GlobalCommandPaletteActions - settings-v3', () => {
+ function renderPalette(organization: ReturnType) {
+ mockOrgApis(organization);
+
+ render(
+
+
+
+
+ ,
+ {
+ organization,
+ initialRouterConfig: {
+ location: {pathname: `/organizations/${organization.slug}/issues/`},
+ },
+ }
+ );
}
+ it('lists account pages under Account Settings', async () => {
+ renderPalette(OrganizationFixture({features: ['settings-v3']}));
+
+ const input = await screen.findByRole('textbox', {name: 'Search commands'});
+ await userEvent.type(input, 'Personal Tokens');
+
+ expect(
+ await screen.findByRole('option', {name: /Account Settings.*Personal Tokens/})
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('option', {name: /Org Settings.*Personal Tokens/})
+ ).not.toBeInTheDocument();
+ });
+
+ it('titles the organization index page "General"', async () => {
+ renderPalette(OrganizationFixture({features: ['settings-v3']}));
+
+ const input = await screen.findByRole('textbox', {name: 'Search commands'});
+ await userEvent.type(input, 'General');
+
+ expect(
+ await screen.findByRole('option', {name: /Org Settings.*General/})
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('option', {name: /General Settings/})
+ ).not.toBeInTheDocument();
+ });
+
+ it('keeps account pages under Settings without the flag', async () => {
+ renderPalette(OrganizationFixture({features: []}));
+
+ const input = await screen.findByRole('textbox', {name: 'Search commands'});
+ await userEvent.type(input, 'Personal Tokens');
+
+ expect(
+ await screen.findByRole('option', {name: /Settings.*Personal Tokens/})
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('option', {name: /Account Settings/})
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('GlobalCommandPaletteActions - Seer XRay Mode gating', () => {
function renderPalette(organization: ReturnType) {
render(
diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
index 42b4248693cd..fb477d27ec28 100644
--- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
+++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx
@@ -12,19 +12,16 @@ import {IconGithub} from '@sentry/icons/github';
import {IconGraph} from '@sentry/icons/graph';
import {IconGroup} from '@sentry/icons/group';
import {IconIssues} from '@sentry/icons/issues';
-import {IconLink} from '@sentry/icons/link';
import {IconList} from '@sentry/icons/list';
import {IconLock} from '@sentry/icons/lock';
import {IconOpen} from '@sentry/icons/open';
import {IconPlay} from '@sentry/icons/play';
-import {IconRepository} from '@sentry/icons/repository';
import {IconSearch} from '@sentry/icons/search';
import {IconSeer} from '@sentry/icons/seer';
import {IconSettings} from '@sentry/icons/settings';
import {IconSiren} from '@sentry/icons/siren';
import {IconStar} from '@sentry/icons/star';
import {IconSubscribed} from '@sentry/icons/subscribed';
-import {IconTerminal} from '@sentry/icons/terminal';
import {IconUser} from '@sentry/icons/user';
import * as Sentry from '@sentry/react';
import {skipToken, useMutation, useQuery} from '@tanstack/react-query';
@@ -103,10 +100,22 @@ import {
toggleXRayMode,
useXRayModeEnabled,
} from 'sentry/views/seerExplorer/xray/xrayModeStore';
-import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration';
+import {
+ getUserNavigationConfiguration,
+ getUserOrgNavigationConfiguration,
+} from 'sentry/views/settings/organization/userOrgNavigationConfiguration';
import {getNavigationConfiguration} from 'sentry/views/settings/project/navigationConfiguration';
import {PROJECT_SETTINGS_ICONS} from 'sentry/views/settings/project/projectSettingsCommandPaletteActions';
-import type {NavigationGroupProps, NavigationItem} from 'sentry/views/settings/types';
+import {
+ getSettingsScopeIcon,
+ getSettingsScopeTitle,
+} from 'sentry/views/settings/settingsScope';
+import type {
+ NavigationGroupProps,
+ NavigationItem,
+ NavigationSection,
+} from 'sentry/views/settings/types';
+import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
/**
* Returns true if a settings navigation item should be shown in cmd+k.
@@ -134,20 +143,6 @@ const DSN_ICONS: React.ReactElement[] = [
,
];
-const ORG_SETTINGS_ICONS: Record = {
- '/settings/:orgId/api-keys/': ,
- '/settings/:orgId/auth-tokens/': ,
- '/settings/:orgId/feature-flags/': ,
- '/settings/:orgId/projects/': ,
- '/settings/:orgId/integrations/': ,
- '/settings/:orgId/mcp-cli/': ,
- '/settings/:orgId/members/': ,
- '/settings/:orgId/repos/': ,
- '/settings/:orgId/seer/': ,
- '/settings/:orgId/teams/': ,
- '/settings/account/notifications/': ,
-};
-
const helpSearch = new SentryGlobalSearch(['docs', 'develop']);
const EVENT_ID_PATTERN =
/^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12})$/;
@@ -311,6 +306,7 @@ export function GlobalCommandPaletteActions() {
const {projects} = useProjects();
const {organizations} = useLegacyStore(OrganizationsStore);
const sentryConfig = useLegacyStore(ConfigStore);
+ const settingsV3 = useSettingsV3();
const params = useParams();
const location = useLocation();
const {mutateAsync: mutateUserOptions} = useMutateUserOptions();
@@ -349,6 +345,7 @@ export function GlobalCommandPaletteActions() {
};
return getNavigationConfiguration({
organization,
+ settingsV3,
})
.flatMap(section =>
section.items.filter(navItem =>
@@ -356,23 +353,35 @@ export function GlobalCommandPaletteActions() {
)
)
.sort((a, b) => a.title.localeCompare(b.title));
- }, [organization]);
+ }, [organization, settingsV3]);
- const visibleOrgSettingsNavItems = useMemo(() => {
+ const {visibleOrgSettingsNavItems, visibleAccountSettingsNavItems} = useMemo(() => {
const context: Omit = {
access: new Set(organization.access),
features: new Set(organization.features),
isSelfHosted: sentryConfig.isSelfHosted,
organization,
};
- return getUserOrgNavigationConfiguration()
- .flatMap(section =>
- section.items.filter(navItem =>
- isNavItemVisible(navItem, {...context, ...section})
+ const getVisibleItems = (sections: NavigationSection[]) =>
+ sections
+ .flatMap(section =>
+ section.items.filter(navItem =>
+ isNavItemVisible(navItem, {...context, ...section})
+ )
)
- )
- .sort((a, b) => a.title.localeCompare(b.title));
- }, [organization, sentryConfig.isSelfHosted]);
+ .sort((a, b) => a.title.localeCompare(b.title));
+
+ return {
+ visibleOrgSettingsNavItems: getVisibleItems(
+ getUserOrgNavigationConfiguration({settingsV3})
+ ),
+ // With settings-v3, account pages leave the organization settings for
+ // their own scope, so they get their own group here too.
+ visibleAccountSettingsNavItems: settingsV3
+ ? getVisibleItems(getUserNavigationConfiguration({settingsV3}))
+ : [],
+ };
+ }, [organization, sentryConfig.isSelfHosted, settingsV3]);
const prefix = `/organizations/${organization.slug}`;
const hasInsightsRollout = organization.features.includes(
@@ -621,11 +630,17 @@ export function GlobalCommandPaletteActions() {
/>
- }} limit={4}>
+ ,
+ }}
+ limit={4}
+ >
{visibleOrgSettingsNavItems.map(item => (
@@ -633,6 +648,25 @@ export function GlobalCommandPaletteActions() {
+ {settingsV3 && (
+
+ {visibleAccountSettingsNavItems.map(item => (
+
+ ))}
+
+ )}
+
}}
to={makeProjectsPathname({path: '/', organization})}
@@ -704,7 +738,10 @@ export function GlobalCommandPaletteActions() {
)}
}}
+ display={{
+ label: getSettingsScopeTitle('project'),
+ icon: settingsV3 ? getSettingsScopeIcon('project') : ,
+ }}
limit={4}
>
{currentProjects.map(project => (
diff --git a/static/app/components/replays/table/deleteReplays.tsx b/static/app/components/replays/table/deleteReplays.tsx
index d6a8fda57fb1..1010dfd1d68c 100644
--- a/static/app/components/replays/table/deleteReplays.tsx
+++ b/static/app/components/replays/table/deleteReplays.tsx
@@ -37,6 +37,8 @@ import {useOrganization} from 'sentry/utils/useOrganization';
import {useProjectFromId} from 'sentry/utils/useProjectFromId';
import {useProjects} from 'sentry/utils/useProjects';
import type {ReplayListRecord} from 'sentry/views/explore/replays/types';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
+import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
const REPLAY_COLUMNS: TableColumnConfig[] = [
{key: 'replay', width: '1fr'},
@@ -265,6 +267,7 @@ function ReplayPreviewTable({
function Title({children, project}: {children: React.ReactNode; project: Project}) {
const organization = useOrganization();
+ const settingsV3 = useSettingsV3();
const settingsPath = `/settings/${organization.slug}/projects/${project.slug}/replays/?replaySettingsTab=bulk-delete`;
return (
@@ -276,7 +279,7 @@ function Title({children, project}: {children: React.ReactNode; project: Project
link: (
{`Settings > ${project.slug} > Replays`}
+ >{`${settingsV3 ? getSettingsScopeTitle('project') : 'Settings'} > ${project.slug} > Replays`}
),
})}
diff --git a/static/app/views/navigation/navigation.tsx b/static/app/views/navigation/navigation.tsx
index 8712f6c9e846..dddfa98f2572 100644
--- a/static/app/views/navigation/navigation.tsx
+++ b/static/app/views/navigation/navigation.tsx
@@ -1,17 +1,14 @@
import {Fragment, type PropsWithChildren, type RefObject, useMemo, useRef} from 'react';
import {mergeProps} from '@react-aria/utils';
import {IconArrow} from '@sentry/icons/arrow';
-import {IconBuilding} from '@sentry/icons/building';
import {IconCompass} from '@sentry/icons/compass';
import {IconDashboard} from '@sentry/icons/dashboard';
import {IconGraph} from '@sentry/icons/graph';
import {IconIssues} from '@sentry/icons/issues';
import {IconMail} from '@sentry/icons/mail';
-import {IconProject} from '@sentry/icons/project';
import {IconSeer} from '@sentry/icons/seer';
import {IconSettings} from '@sentry/icons/settings';
import {IconSiren} from '@sentry/icons/siren';
-import {IconUser} from '@sentry/icons/user';
import {motion, type MotionProps} from 'framer-motion';
import {LinkButton} from '@sentry/scraps/button';
@@ -63,12 +60,17 @@ import {
getAutomationsPath,
useAutomationsEnabled,
} from 'sentry/views/seerWorkflows/utils';
+import {
+ getSettingsScopeIcon,
+ type SettingsScope,
+} from 'sentry/views/settings/settingsScope';
import {useSettingsScope} from 'sentry/views/settings/useSettingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
function NavigationImpl() {
const organization = useOrganization({allowNull: true});
const isSettingsNavigation = useSettingsNavigation();
+ const settingsV3 = useSettingsV3();
const collapsedNavigation = useCollapsedNavigation();
const {view} = useSecondaryNavigation();
@@ -84,12 +86,16 @@ function NavigationImpl() {
const isCollapsed = currentStepId === null ? view !== 'expanded' : false;
useLLMContext({
- contextHint:
- "The org's left-hand navigation sidebar — a primary icon rail (Issues, " +
- 'Explore, Dashboards, Insights, Monitors, Settings, ...) plus a secondary ' +
- 'panel for whichever group is active. Child nodes carry each active ' +
- "secondary panel's own detail (starred views/queries/dashboards, issue " +
- 'counts, settings categories, etc).',
+ contextHint: isSettingsNavigation
+ ? "The settings navigation sidebar — a primary icon rail with a 'Back to app' " +
+ 'button and the Org, Project and Account settings scopes, plus a secondary ' +
+ 'panel listing the settings pages of the active scope. Child nodes carry ' +
+ "the secondary panel's own detail."
+ : "The org's left-hand navigation sidebar — a primary icon rail (Issues, " +
+ `Explore, Dashboards, Insights, Monitors${settingsV3 ? '' : ', Settings'}, ...) ` +
+ 'plus a secondary panel for whichever group is active. Child nodes carry ' +
+ "each active secondary panel's own detail (starred views/queries/dashboards, " +
+ 'issue counts, settings categories, etc).',
activeGroup,
isCollapsed,
});
@@ -207,23 +213,17 @@ export function SettingsPrimaryNavigationItems({
const {scope: activeScope, projectSlug: activeProjectSlug} = useSettingsScope();
const projectSlug = activeProjectSlug ?? projects[0]?.slug;
const projectSettingsPath = `/settings/${organizationSlug}/projects/`;
- const items = [
- {
- key: 'org',
- label: t('Org'),
- to: `/settings/${organizationSlug}/`,
- icon: ,
- },
+ const items: Array<{key: SettingsScope; label: string; to: string}> = [
+ {key: 'org', label: t('Org'), to: `/settings/${organizationSlug}/`},
{
key: 'project',
label: t('Project'),
to: projectSlug ? `${projectSettingsPath}${projectSlug}/` : projectSettingsPath,
- icon: ,
},
- {key: 'account', label: t('Account'), to: '/settings/account/', icon: },
+ {key: 'account', label: t('Account'), to: '/settings/account/'},
];
- return items.map(({key, label, to, icon}) => (
+ return items.map(({key, label, to}) => (
- {icon}
+ {getSettingsScopeIcon(key)}
));
diff --git a/static/app/views/navigation/primary/organizationDropdown.tsx b/static/app/views/navigation/primary/organizationDropdown.tsx
index 015ae2efbbe9..f5565db23885 100644
--- a/static/app/views/navigation/primary/organizationDropdown.tsx
+++ b/static/app/views/navigation/primary/organizationDropdown.tsx
@@ -51,13 +51,16 @@ import {
useSettingsNavigationTour,
} from 'sentry/views/navigation/settingsNavigationTour';
import {makeProjectsPathname} from 'sentry/views/projects/pathname';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
import {NavigationMenuHeader} from './navigationMenuHeader';
interface OrganizationDropdownProps {
/**
- * When true, hides settings, projects, members, teams, and billing links for the current organization.
+ * When true, hides the links into the current organization. Without
+ * settings-v3, these are settings, projects, members, teams, and billing.
+ * With settings-v3, these are settings and project settings.
*/
hideCurrentOrganizationLinks?: boolean;
}
@@ -160,7 +163,7 @@ export function OrganizationDropdown(props: OrganizationDropdownProps) {
returnFocusRef={triggerRef}
title={
hasNoProjects
- ? t('Project Settings')
+ ? getSettingsScopeTitle('project')
: t('Quick settings-access for your important Projects')
}
description={
@@ -245,7 +248,7 @@ export function OrganizationDropdown(props: OrganizationDropdownProps) {
},
{
key: 'project-settings',
- label: t('Project Settings'),
+ label: getSettingsScopeTitle('project'),
ref: projectsTourProps.ref,
hidden: props.hideCurrentOrganizationLinks,
children: [
diff --git a/static/app/views/settings/account/accountDetails.tsx b/static/app/views/settings/account/accountDetails.tsx
index 0351a6a79080..6cb50a79ef59 100644
--- a/static/app/views/settings/account/accountDetails.tsx
+++ b/static/app/views/settings/account/accountDetails.tsx
@@ -18,6 +18,7 @@ import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {fetchMutation, setApiQueryData, useApiQuery} from 'sentry/utils/queryClient';
import {removeBodyTheme} from 'sentry/utils/removeBodyTheme';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
// The avatar endpoint ("/users/me/avatar/") returns a User-like type without `options` and other properties that are present in User
@@ -166,7 +167,9 @@ function AccountDetails() {
return (
-
+
diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx
index dc29b6854552..f2236e5c53d7 100644
--- a/static/app/views/settings/components/settingsNavigation.tsx
+++ b/static/app/views/settings/components/settingsNavigation.tsx
@@ -12,7 +12,6 @@ import {Text} from '@sentry/scraps/text';
import {ErrorBoundary} from 'sentry/components/errorBoundary';
import {t} from 'sentry/locale';
import {replaceRouterParams} from 'sentry/utils/replaceRouterParams';
-import {unreachable} from 'sentry/utils/unreachable';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useCanCreateProject} from 'sentry/utils/useCanCreateProject';
import {useLocation} from 'sentry/utils/useLocation';
@@ -23,12 +22,10 @@ import {useProjects} from 'sentry/utils/useProjects';
import {SecondaryNavigation} from 'sentry/views/navigation/secondary/components';
import {makeProjectsPathname} from 'sentry/views/projects/pathname';
import {SettingsNavigationGroup} from 'sentry/views/settings/components/settingsNavigationGroup';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
import type {NavigationProps, NavigationSection} from 'sentry/views/settings/types';
import {useSettingsProjectSwitchTemplate} from 'sentry/views/settings/useSettingsProjectSwitchTemplate';
-import {
- useSettingsScope,
- type SettingsScope,
-} from 'sentry/views/settings/useSettingsScope';
+import {useSettingsScope} from 'sentry/views/settings/useSettingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
type Props = NavigationProps & {
@@ -46,19 +43,6 @@ type Props = NavigationProps & {
navigationObjects: NavigationSection[];
};
-function getSettingsScopeTitle(scope: SettingsScope): string {
- switch (scope) {
- case 'account':
- return t('Account Settings');
- case 'project':
- return t('Project Settings');
- case 'org':
- return t('Org Settings');
- default:
- return unreachable(scope);
- }
-}
-
export function SettingsNavigation({
navigationObjects,
hookConfigs,
diff --git a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx
index b9333024d9cb..cf199dcd969d 100644
--- a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx
+++ b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx
@@ -1,8 +1,21 @@
+import {IconAllProjects} from '@sentry/icons/allProjects';
+import {IconFlag} from '@sentry/icons/flag';
+import {IconGroup} from '@sentry/icons/group';
+import {IconLink} from '@sentry/icons/link';
+import {IconLock} from '@sentry/icons/lock';
+import {IconMail} from '@sentry/icons/mail';
+import {IconRepository} from '@sentry/icons/repository';
+import {IconSeer} from '@sentry/icons/seer';
+import {IconSubscribed} from '@sentry/icons/subscribed';
+import {IconTerminal} from '@sentry/icons/terminal';
+import {IconUser} from '@sentry/icons/user';
+
import {FeatureBadge} from '@sentry/scraps/badge';
import {t} from 'sentry/locale';
import {hasDynamicSamplingCustomFeature} from 'sentry/utils/dynamicSampling/features';
import {showNewSeer} from 'sentry/utils/seer/showNewSeer';
+import {getSettingsScopeIcon} from 'sentry/views/settings/settingsScope';
import type {NavigationItem, NavigationSection} from 'sentry/views/settings/types';
const organizationSettingsPathPrefix = '/settings/:orgId';
@@ -53,6 +66,7 @@ function getAccountNavigationConfiguration({
items: [
{
path: `${userSettingsPathPrefix}/details/`,
+ icon: getSettingsScopeIcon('account'),
title: settingsV3 ? t('General') : t('Account Details'),
keywords: [t('user settings'), t('account settings')],
description: t(
@@ -61,17 +75,20 @@ function getAccountNavigationConfiguration({
},
{
path: `${userSettingsPathPrefix}/security/`,
+ icon: ,
title: t('Security'),
description: t('Change your account password and/or two factor authentication'),
},
{
path: `${userSettingsPathPrefix}/notifications/`,
+ icon: ,
title: t('Notifications'),
keywords: [t('weekly report')],
description: t('Configure what email notifications to receive'),
},
{
path: `${userSettingsPathPrefix}/emails/`,
+ icon: ,
title: t('Email Addresses'),
description: t(
'Add or remove secondary emails, change your primary email, verify your emails'
@@ -119,6 +136,7 @@ function getOrganizationNavigationConfiguration({
items: [
{
path: `${organizationSettingsPathPrefix}/`,
+ icon: getSettingsScopeIcon('org'),
title: settingsV3 ? t('General') : t('General Settings'),
keywords: [t('slug'), t('org slug'), t('organization slug')],
index: true,
@@ -134,24 +152,28 @@ function getOrganizationNavigationConfiguration({
},
{
path: `${organizationSettingsPathPrefix}/projects/`,
+ icon: ,
title: t('Projects'),
description: t("View and manage an organization's projects"),
id: 'projects',
},
{
path: `${organizationSettingsPathPrefix}/teams/`,
+ icon: ,
title: t('Teams'),
description: t("Manage an organization's teams"),
id: 'teams',
},
{
path: `${organizationSettingsPathPrefix}/members/`,
+ icon: ,
title: t('Members'),
description: t('Manage user membership for an organization'),
id: 'members',
},
{
path: `${organizationSettingsPathPrefix}/security-and-privacy/`,
+ icon: ,
title: t('Security & Privacy'),
keywords: [
t('data scrubbing'),
@@ -174,6 +196,7 @@ function getOrganizationNavigationConfiguration({
},
{
path: `${organizationSettingsPathPrefix}/api-keys/`,
+ icon: ,
title: t('API Keys'),
show: false, // deprecated: hide from settings nav and cmd+k (SEC-551)
id: 'api-keys',
@@ -216,6 +239,7 @@ function getOrganizationNavigationConfiguration({
},
{
path: `${organizationSettingsPathPrefix}/feature-flags/`,
+ icon: ,
title: t('Feature Flags'),
description: t('Set up feature flag integrations'),
},
@@ -235,6 +259,7 @@ function getOrganizationNavigationConfiguration({
items: [
{
path: `${organizationSettingsPathPrefix}/seer/`,
+ icon: ,
title: t('Issue Scans & Fixes'),
description: t("Manage Seer's automated issue analysis across your projects"),
id: 'seer-autofix-legacy',
@@ -284,12 +309,14 @@ function getOrganizationNavigationConfiguration({
items: [
{
path: `${organizationSettingsPathPrefix}/mcp-cli/`,
+ icon: ,
title: t('MCP & CLI'),
description: t('Connect to Sentry via MCP server or the Sentry CLI'),
id: 'mcp-cli',
},
{
path: `${organizationSettingsPathPrefix}/integrations/`,
+ icon: ,
title: t('Integrations'),
keywords: [
t('slack'),
@@ -324,6 +351,7 @@ function getOrganizationNavigationConfiguration({
},
{
path: `${organizationSettingsPathPrefix}/repos/`,
+ icon: ,
title: t('Repositories'),
description: t('Manage repositories connected to the organization'),
id: 'repos',
@@ -388,6 +416,7 @@ function getDeveloperNavigationConfiguration({
if (organization) {
items.unshift({
path: `${organizationSettingsPathPrefix}/auth-tokens/`,
+ icon: ,
title: t('Organization Tokens'),
keywords: [
t('auth'),
diff --git a/static/app/views/settings/organizationGeneralSettings/index.tsx b/static/app/views/settings/organizationGeneralSettings/index.tsx
index ee2ebeb370c1..3996c4fbea32 100644
--- a/static/app/views/settings/organizationGeneralSettings/index.tsx
+++ b/static/app/views/settings/organizationGeneralSettings/index.tsx
@@ -26,6 +26,7 @@ import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageH
import {TextBlock} from 'sentry/views/settings/components/text/textBlock';
import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert';
import {defaultEnableSeerFeaturesValue} from 'sentry/views/settings/organizationGeneralSettings/aiFeatureSettings';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
import {OrganizationSettingsForm} from './organizationSettingsForm';
@@ -97,7 +98,10 @@ export default function OrganizationGeneralSettings() {
return (
-
+
{
])
);
});
+
+ it('titles the index item "General" with settings-v3', () => {
+ const organization = OrganizationFixture({slug: 'acme'});
+ const project = DetailedProjectFixture({slug: 'frontend'});
+
+ const sections = getProjectSettingsCommandPaletteSections({
+ organization,
+ project,
+ settingsV3: true,
+ });
+
+ expect(sections).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ label: 'frontend',
+ items: expect.arrayContaining([
+ expect.objectContaining({
+ label: 'General',
+ items: expect.arrayContaining([
+ expect.objectContaining({
+ display: expect.objectContaining({label: 'General'}),
+ to: '/settings/acme/projects/frontend/',
+ }),
+ ]),
+ }),
+ ]),
+ }),
+ ])
+ );
+ });
});
diff --git a/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx
index 28b1aa0b0b46..086af9c1d7c7 100644
--- a/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx
+++ b/static/app/views/settings/project/projectSettingsCommandPaletteActions.tsx
@@ -10,7 +10,6 @@ import {IconPlay} from '@sentry/icons/play';
import {IconProject} from '@sentry/icons/project';
import {IconReleases} from '@sentry/icons/releases';
import {IconSeer} from '@sentry/icons/seer';
-import {IconSettings} from '@sentry/icons/settings';
import {IconSiren} from '@sentry/icons/siren';
import {IconStack} from '@sentry/icons/stack';
import {IconTag} from '@sentry/icons/tag';
@@ -24,7 +23,9 @@ 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 {getSettingsScopeIcon} from 'sentry/views/settings/settingsScope';
import type {NavigationGroupProps, NavigationItem} from 'sentry/views/settings/types';
+import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
type ProjectSettingsCommandPaletteEntry = {
display: {
@@ -55,7 +56,7 @@ type ProjectSettingsCommandPaletteSection = {
};
export const PROJECT_SETTINGS_ICONS: Record = {
- '': ,
+ '': getSettingsScopeIcon('project'),
'alerts/': ,
'debug-symbols/': ,
'environments/': ,
@@ -86,9 +87,11 @@ function shouldShowItem(
export function getProjectSettingsCommandPaletteSections({
organization,
project,
+ settingsV3,
}: {
organization: Organization;
project: Project;
+ settingsV3?: boolean;
}): ProjectSettingsCommandPaletteSection[] {
const context = {
access: new Set(organization.access),
@@ -107,6 +110,7 @@ export function getProjectSettingsCommandPaletteSections({
debugFilesNeedsReview: false,
organization,
project,
+ settingsV3,
})
.map(section => {
const label =
@@ -184,7 +188,12 @@ export function ProjectSettingsCommandPaletteActions({
organization: Organization;
project: Project;
}) {
- const sections = getProjectSettingsCommandPaletteSections({organization, project});
+ const settingsV3 = useSettingsV3();
+ const sections = getProjectSettingsCommandPaletteSections({
+ organization,
+ project,
+ settingsV3,
+ });
return (
diff --git a/static/app/views/settings/projectGeneralSettings/index.tsx b/static/app/views/settings/projectGeneralSettings/index.tsx
index f23adbd5d949..5235c737faf9 100644
--- a/static/app/views/settings/projectGeneralSettings/index.tsx
+++ b/static/app/views/settings/projectGeneralSettings/index.tsx
@@ -64,6 +64,7 @@ import {TextBlock} from 'sentry/views/settings/components/text/textBlock';
import {ProjectPermissionAlert} from 'sentry/views/settings/project/projectPermissionAlert';
import {useProjectSettingsOutlet} from 'sentry/views/settings/project/projectSettingsLayout';
import {ConnectedRepositoriesPanel} from 'sentry/views/settings/projectGeneralSettings/connectedRepositoriesPanel';
+import {getSettingsScopeTitle} from 'sentry/views/settings/settingsScope';
import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
type Props = {
@@ -678,7 +679,10 @@ export function ProjectGeneralSettings({project, onChangeSlug}: Props) {
return (
-
+
diff --git a/static/app/views/settings/settingsCommandPaletteActions.spec.tsx b/static/app/views/settings/settingsCommandPaletteActions.spec.tsx
new file mode 100644
index 000000000000..5e68808d7740
--- /dev/null
+++ b/static/app/views/settings/settingsCommandPaletteActions.spec.tsx
@@ -0,0 +1,49 @@
+import {IconBuilding} from '@sentry/icons/building';
+import {IconUser} from '@sentry/icons/user';
+
+import {getSettingsFieldSections} from 'sentry/views/settings/settingsCommandPaletteActions';
+
+function getSection(sections: ReturnType
, key: string) {
+ const section = sections.find(s => s.key === key);
+ if (!section) {
+ throw new Error(`No field section for ${key}`);
+ }
+ return section;
+}
+
+describe('getSettingsFieldSections', () => {
+ it('names shared titles after their scope with settings-v3', () => {
+ const sections = getSettingsFieldSections('org-slug', true);
+
+ expect(getSection(sections, '/settings/:orgId/').title).toBe(
+ 'Org Settings › General'
+ );
+ expect(getSection(sections, '/settings/account/details/').title).toBe(
+ 'Account Settings › General'
+ );
+ // Titles unique to one scope are left alone.
+ expect(getSection(sections, '/settings/account/notifications/').title).toBe(
+ 'Notifications'
+ );
+ });
+
+ it('keeps the original titles without settings-v3', () => {
+ const sections = getSettingsFieldSections('org-slug', false);
+
+ expect(getSection(sections, '/settings/:orgId/').title).toBe('General Settings');
+ expect(getSection(sections, '/settings/account/details/').title).toBe(
+ 'Account Details'
+ );
+ });
+
+ it.each([true, false])(
+ 'gives the General pages their scope icon (settings-v3: %s)',
+ settingsV3 => {
+ const sections = getSettingsFieldSections('org-slug', settingsV3);
+ expect(getSection(sections, '/settings/:orgId/').icon).toEqual();
+ expect(getSection(sections, '/settings/account/details/').icon).toEqual(
+
+ );
+ }
+ );
+});
diff --git a/static/app/views/settings/settingsCommandPaletteActions.tsx b/static/app/views/settings/settingsCommandPaletteActions.tsx
index cd0bfdd5f662..1b9dd1bc9312 100644
--- a/static/app/views/settings/settingsCommandPaletteActions.tsx
+++ b/static/app/views/settings/settingsCommandPaletteActions.tsx
@@ -1,9 +1,5 @@
import {useMemo, type ReactNode} from 'react';
-import {IconLock} from '@sentry/icons/lock';
-import {IconMail} from '@sentry/icons/mail';
import {IconSettings} from '@sentry/icons/settings';
-import {IconSubscribed} from '@sentry/icons/subscribed';
-import {IconUser} from '@sentry/icons/user';
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
@@ -11,16 +7,16 @@ import {t} from 'sentry/locale';
import {replaceRouterParams} from 'sentry/utils/replaceRouterParams';
import {useOrganization} from 'sentry/utils/useOrganization';
import {FORM_FIELD_REGISTRY} from 'sentry/views/settings/fieldRegistry.generated';
-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/': ,
-};
+import {
+ getUserNavigationConfiguration,
+ getUserOrgNavigationConfiguration,
+} from 'sentry/views/settings/organization/userOrgNavigationConfiguration';
+import {
+ getSettingsScopeTitle,
+ type SettingsScope,
+} from 'sentry/views/settings/settingsScope';
+import type {NavigationSection} from 'sentry/views/settings/types';
+import {useSettingsV3} from 'sentry/views/settings/useSettingsV3';
function normalizeRouteForLookup(route: string): string {
if (route === '/settings/organization/') {
@@ -79,14 +75,61 @@ type SettingsFieldSection = {
type FormFieldDefinition = (typeof FORM_FIELD_REGISTRY)[string];
-function getSettingsFieldSections(orgSlug: string): SettingsFieldSection[] {
- const routeTitleMap = new Map();
- for (const section of getUserOrgNavigationConfiguration()) {
- for (const item of section.items) {
- routeTitleMap.set(item.path, item.title);
+function getNavItems(sections: NavigationSection[]) {
+ return sections.flatMap(section => section.items);
+}
+
+type RouteDisplay = {title: string; icon?: ReactNode};
+
+/**
+ * Title and icon of each settings page, keyed by route, taken from the
+ * settings navigation configuration.
+ */
+function getRouteDisplayMap(settingsV3: boolean): Map {
+ const routeDisplayMap = new Map();
+
+ if (!settingsV3) {
+ for (const item of getNavItems(getUserOrgNavigationConfiguration())) {
+ routeDisplayMap.set(item.path, {title: item.title, icon: item.icon});
}
+ return routeDisplayMap;
}
+ // With settings-v3, org and account pages live in separate scopes and can
+ // share a title (both index pages are "General"). Titles used by more than
+ // one scope are prefixed with the scope's name to tell them apart.
+ const scopedItems: Array<[SettingsScope, ReturnType]> = [
+ ['org', getNavItems(getUserOrgNavigationConfiguration({settingsV3}))],
+ ['account', getNavItems(getUserNavigationConfiguration({settingsV3}))],
+ ];
+
+ const scopesByTitle = new Map>();
+ for (const [scope, items] of scopedItems) {
+ for (const item of items) {
+ const scopes = scopesByTitle.get(item.title) ?? new Set();
+ scopes.add(scope);
+ scopesByTitle.set(item.title, scopes);
+ }
+ }
+
+ for (const [scope, items] of scopedItems) {
+ for (const item of items) {
+ const isShared = (scopesByTitle.get(item.title)?.size ?? 0) > 1;
+ routeDisplayMap.set(item.path, {
+ title: isShared ? `${getSettingsScopeTitle(scope)} › ${item.title}` : item.title,
+ icon: item.icon,
+ });
+ }
+ }
+ return routeDisplayMap;
+}
+
+export function getSettingsFieldSections(
+ orgSlug: string,
+ settingsV3: boolean
+): SettingsFieldSection[] {
+ const routeDisplayMap = getRouteDisplayMap(settingsV3);
+
const groups = new Map>();
for (const field of Object.values(FORM_FIELD_REGISTRY)) {
if (!isSettingsRoute(field.route)) {
@@ -107,13 +150,14 @@ function getSettingsFieldSections(orgSlug: string): SettingsFieldSection[] {
return Array.from(groups.entries())
.map(([route, fieldMap]): SettingsFieldSection => {
- const title = routeTitleMap.get(route) ?? titleFromRoute(route);
+ const routeDisplay = routeDisplayMap.get(route);
+ const title = routeDisplay?.title ?? titleFromRoute(route);
const resolvedPath = resolveRoutePath(route, orgSlug);
return {
key: route,
title,
- icon: ROUTE_ICONS[route],
+ icon: routeDisplay?.icon,
fields: Array.from(fieldMap.values())
.filter(
(f): f is FormFieldDefinition & {label: string} =>
@@ -140,9 +184,10 @@ function getSettingsFieldSections(orgSlug: string): SettingsFieldSection[] {
export function SettingsCommandPaletteActions() {
const organization = useOrganization({allowNull: true});
+ const settingsV3 = useSettingsV3();
const sections = useMemo(
- () => (organization ? getSettingsFieldSections(organization.slug) : []),
- [organization]
+ () => (organization ? getSettingsFieldSections(organization.slug, settingsV3) : []),
+ [organization, settingsV3]
);
if (sections.length === 0) {
diff --git a/static/app/views/settings/settingsScope.tsx b/static/app/views/settings/settingsScope.tsx
new file mode 100644
index 000000000000..6246f10ba08a
--- /dev/null
+++ b/static/app/views/settings/settingsScope.tsx
@@ -0,0 +1,39 @@
+import type {ReactElement} from 'react';
+import {IconBuilding} from '@sentry/icons/building';
+import {IconProject} from '@sentry/icons/project';
+import {IconUser} from '@sentry/icons/user';
+
+import {t} from 'sentry/locale';
+import {unreachable} from 'sentry/utils/unreachable';
+
+export type SettingsScope = 'account' | 'org' | 'project';
+
+export function getSettingsScopeTitle(scope: SettingsScope): string {
+ switch (scope) {
+ case 'account':
+ return t('Account Settings');
+ case 'project':
+ return t('Project Settings');
+ case 'org':
+ return t('Org Settings');
+ default:
+ return unreachable(scope);
+ }
+}
+
+/**
+ * The icon that stands for a settings scope wherever the scope is listed, e.g.
+ * the settings navigation rail and the cmd+k settings groups.
+ */
+export function getSettingsScopeIcon(scope: SettingsScope): ReactElement {
+ switch (scope) {
+ case 'account':
+ return ;
+ case 'project':
+ return ;
+ case 'org':
+ return ;
+ default:
+ return unreachable(scope);
+ }
+}
diff --git a/static/app/views/settings/types.tsx b/static/app/views/settings/types.tsx
index 0462733d61ba..803e94bf62f4 100644
--- a/static/app/views/settings/types.tsx
+++ b/static/app/views/settings/types.tsx
@@ -33,6 +33,10 @@ export type NavigationItem = {
* The description of the settings section. This will be used in search.
*/
description?: string;
+ /**
+ * Icon shown next to the page in the command palette.
+ */
+ icon?: ReactElement;
/**
* A string identifier for the navigation item. May be used in hooks to
* augment navigation items.
diff --git a/static/app/views/settings/useSettingsScope.ts b/static/app/views/settings/useSettingsScope.ts
index d6e569547045..9c080b9bbd13 100644
--- a/static/app/views/settings/useSettingsScope.ts
+++ b/static/app/views/settings/useSettingsScope.ts
@@ -1,8 +1,7 @@
import {matchPath} from 'react-router';
import {useLocation} from 'sentry/utils/useLocation';
-
-export type SettingsScope = 'account' | 'org' | 'project';
+import type {SettingsScope} from 'sentry/views/settings/settingsScope';
/**
* Which settings scope the current route belongs to. Derived from the pathname