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