diff --git a/static/app/components/core/avatar/organizationAvatar.tsx b/static/app/components/core/avatar/organizationAvatar.tsx index 1f2f41420b3f54..96887118818fb3 100644 --- a/static/app/components/core/avatar/organizationAvatar.tsx +++ b/static/app/components/core/avatar/organizationAvatar.tsx @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/react'; + import type {OrganizationSummary} from 'sentry/types/organization'; import {explodeSlug} from 'sentry/utils'; @@ -15,6 +17,7 @@ export function OrganizationAvatar({ }: OrganizationAvatarProps) { if (!organization) { // @TODO(jonasbadalic): Do we need a placeholder here? + Sentry.captureMessage('OrganizationAvatar: organization summary is undefined'); return null; } diff --git a/static/app/views/nav/mobileTopbar.tsx b/static/app/views/nav/mobileTopbar.tsx index a6eaa9b96d6bf6..f005d57790b982 100644 --- a/static/app/views/nav/mobileTopbar.tsx +++ b/static/app/views/nav/mobileTopbar.tsx @@ -14,7 +14,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import useOnClickOutside from 'sentry/utils/useOnClickOutside'; import useOrganization from 'sentry/utils/useOrganization'; import {NAV_MOBILE_TOPBAR_HEIGHT} from 'sentry/views/nav/constants'; -import {OrgDropdown} from 'sentry/views/nav/orgDropdown'; +import {OrganizationDropdown} from 'sentry/views/nav/organizationDropdown'; import {PrimaryNavigationItems} from 'sentry/views/nav/primary/index'; import {SecondaryMobile} from 'sentry/views/nav/secondary/secondaryMobile'; import {useActiveNavGroup} from 'sentry/views/nav/useActiveNavGroup'; @@ -49,7 +49,8 @@ function MobileTopbar() { return ( - setView('closed')} /> + {/* If the view is not closed, it will render under the full screen mobile menu */} + setView('closed')} /> {showSuperuserWarning && ( )} diff --git a/static/app/views/nav/orgDropdown.tsx b/static/app/views/nav/orgDropdown.tsx deleted file mode 100644 index 2e2f833a6c7cd4..00000000000000 --- a/static/app/views/nav/orgDropdown.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import {useCallback} from 'react'; -import styled from '@emotion/styled'; -import orderBy from 'lodash/orderBy'; -import partition from 'lodash/partition'; - -import {OrganizationAvatar} from 'sentry/components/core/avatar/organizationAvatar'; -import {Button} from 'sentry/components/core/button'; -import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; -import OrganizationBadge from 'sentry/components/idBadge/organizationBadge'; -import QuestionTooltip from 'sentry/components/questionTooltip'; -import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; -import {IconAdd} from 'sentry/icons'; -import {t, tn} from 'sentry/locale'; -import ConfigStore from 'sentry/stores/configStore'; -import OrganizationsStore from 'sentry/stores/organizationsStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import type {Organization} from 'sentry/types/organization'; -import {isDemoModeActive} from 'sentry/utils/demoMode'; -import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; -import useProjects from 'sentry/utils/useProjects'; -import {useSessionStorage} from 'sentry/utils/useSessionStorage'; -import {useNavContext} from 'sentry/views/nav/context'; -import {NavLayout} from 'sentry/views/nav/types'; -import {makeProjectsPathname} from 'sentry/views/projects/pathname'; - -const ORG_DROPDOWN_REFERRER = 'org-dropdown'; - -function createOrganizationMenuItem(): MenuItemProps { - const configFeatures = ConfigStore.get('features'); - const sentryUrl = localizeDomain(ConfigStore.get('links').sentryUrl); - const route = '/organizations/new/'; - const canCreateOrg = ConfigStore.get('features').has('organizations:create'); - - const menuItemProps: MenuItemProps = { - key: 'create-organization', - leadingItems: , - label: t('Create a new organization'), - }; - - if (configFeatures.has('system:multi-region')) { - menuItemProps.externalHref = sentryUrl + route; - } else { - menuItemProps.to = route; - } - - return { - key: 'create-organization-section', - children: [menuItemProps], - hidden: !canCreateOrg, - }; -} - -export function OrgDropdown({ - className, - onClick, - hideOrgLinks, -}: { - className?: string; - hideOrgLinks?: boolean; - onClick?: () => void; -}) { - const config = useLegacyStore(ConfigStore); - const organization = useOrganization(); - const navigate = useNavigate(); - const [, setReferrer] = useSessionStorage(CUSTOM_REFERRER_KEY, null); - - // It's possible we do not have an org in context (e.g. RouteNotFound) - // Otherwise, we should have the full org - const hasOrgRead = organization.access?.includes('org:read'); - const hasMemberRead = organization.access?.includes('member:read'); - const hasTeamRead = organization.access?.includes('team:read'); - const hasBillingAccess = organization.access?.includes('org:billing'); - - const {organizations} = useLegacyStore(OrganizationsStore); - const [activeOrgs, inactiveOrgs] = partition( - organizations.filter(org => org.slug !== organization.slug), - org => org.status.id === 'active' - ); - - const makeOrganizationMenuItem = useCallback( - (org: Organization): MenuItemProps => ({ - key: org.id, - label: , - textValue: org.name, - to: resolveRoute(`/organizations/${org.slug}/issues/`, organization, org), - }), - [organization] - ); - - const makeInactiveOrganizationMenuItem = useCallback( - (org: Organization): MenuItemProps => ({ - ...makeOrganizationMenuItem(org), - trailingItems: , - }), - [makeOrganizationMenuItem] - ); - - const {projects} = useProjects(); - - const {layout} = useNavContext(); - const isMobile = layout === NavLayout.MOBILE; - - return ( - ( - { - props.onClick?.(e); - onClick?.(); - }} - > - - - )} - position="right-start" - minMenuWidth={200} - items={[ - { - key: 'organization', - label: ( - - - - ), - children: [ - { - key: 'organization-settings', - label: t('Organization Settings'), - to: `/settings/${organization.slug}/`, - hidden: !hasOrgRead || hideOrgLinks, - }, - { - key: 'projects', - label: t('Projects'), - onAction: () => { - setReferrer(ORG_DROPDOWN_REFERRER); - navigate(makeProjectsPathname({path: '/', organization})); - }, - hidden: hideOrgLinks, - }, - { - key: 'members', - label: t('Members'), - to: `/settings/${organization.slug}/members/`, - hidden: !hasMemberRead || hideOrgLinks, - }, - { - key: 'teams', - label: t('Teams'), - to: `/settings/${organization.slug}/teams/`, - hidden: !hasTeamRead || hideOrgLinks, - }, - { - key: 'billing', - label: t('Usage & Billing'), - to: `/settings/${organization.slug}/billing/`, - hidden: !hasBillingAccess || hideOrgLinks, - }, - { - key: 'switch-organization', - label: t('Switch Organization'), - isSubmenu: true, - hidden: config.singleOrganization || isDemoModeActive(), - children: [ - ...(activeOrgs.length === 0 - ? [] - : [ - { - key: 'active-orgs', - children: orderBy(activeOrgs, ['name']).map( - makeOrganizationMenuItem - ), - }, - ]), - ...(inactiveOrgs.length === 0 - ? [] - : [ - { - key: 'inactive-ogs', - children: orderBy(inactiveOrgs, ['name']).map( - makeInactiveOrganizationMenuItem - ), - }, - ]), - createOrganizationMenuItem(), - ], - }, - ], - }, - ]} - /> - ); -} - -const OrgDropdownTrigger = styled(Button)<{width: number}>` - height: ${p => p.width}px; - width: ${p => p.width}px; - min-height: ${p => `${p.width}px`}; - padding: 0; /* Without this the icon will be cutoff due to overflow */ -`; - -const StyledOrganizationAvatar = styled(OrganizationAvatar)` - border-radius: 6px; /* Fixes background bleeding on corners */ -`; - -const SectionTitleWrapper = styled('div')` - text-transform: none; - font-size: ${p => p.theme.fontSize.md}; - font-weight: ${p => p.theme.fontWeight.normal}; - color: ${p => p.theme.tokens.content.primary}; -`; diff --git a/static/app/views/nav/orgDropdown.spec.tsx b/static/app/views/nav/organizationDropdown.spec.tsx similarity index 92% rename from static/app/views/nav/orgDropdown.spec.tsx rename to static/app/views/nav/organizationDropdown.spec.tsx index 658683fb491924..e3b9f34e9c085a 100644 --- a/static/app/views/nav/orgDropdown.spec.tsx +++ b/static/app/views/nav/organizationDropdown.spec.tsx @@ -7,9 +7,9 @@ import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; import ConfigStore from 'sentry/stores/configStore'; import OrganizationsStore from 'sentry/stores/organizationsStore'; import {readStorageValue} from 'sentry/utils/useSessionStorage'; -import {OrgDropdown} from 'sentry/views/nav/orgDropdown'; +import {OrganizationDropdown} from 'sentry/views/nav/organizationDropdown'; -describe('OrgDropdown', () => { +describe('OrganizationDropdown', () => { const organization = OrganizationFixture({ access: ['org:read', 'member:read', 'team:read'], }); @@ -19,7 +19,7 @@ describe('OrgDropdown', () => { }); it('displays org info and links', async () => { - render(, {organization}); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'})); @@ -47,7 +47,7 @@ describe('OrgDropdown', () => { OrganizationFixture({id: '2', name: 'Org 2', slug: 'org-2'}) ); - render(, {organization}); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'})); await userEvent.hover(screen.getByText('Switch Organization')); @@ -83,7 +83,7 @@ describe('OrgDropdown', () => { }) ); - render(, {organization}); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'})); await userEvent.hover(screen.getByText('Switch Organization')); @@ -106,7 +106,7 @@ describe('OrgDropdown', () => { }); it('clicking project sets referrer in session storage', async () => { - render(, {organization}); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'})); // We use onAction to navigate, no href is set: expect(screen.getByRole('menuitemradio', {name: 'Projects'})).not.toHaveAttribute( diff --git a/static/app/views/nav/organizationDropdown.tsx b/static/app/views/nav/organizationDropdown.tsx new file mode 100644 index 00000000000000..e01346f54ff01c --- /dev/null +++ b/static/app/views/nav/organizationDropdown.tsx @@ -0,0 +1,203 @@ +import styled from '@emotion/styled'; +import orderBy from 'lodash/orderBy'; +import partition from 'lodash/partition'; + +import {OrganizationAvatar} from 'sentry/components/core/avatar/organizationAvatar'; +import {Button} from 'sentry/components/core/button'; +import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; +import OrganizationBadge from 'sentry/components/idBadge/organizationBadge'; +import QuestionTooltip from 'sentry/components/questionTooltip'; +import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; +import {IconAdd} from 'sentry/icons'; +import {t, tn} from 'sentry/locale'; +import ConfigStore from 'sentry/stores/configStore'; +import OrganizationsStore from 'sentry/stores/organizationsStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import type {Organization} from 'sentry/types/organization'; +import {isDemoModeActive} from 'sentry/utils/demoMode'; +import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import useOrganization from 'sentry/utils/useOrganization'; +import useProjects from 'sentry/utils/useProjects'; +import {useSessionStorage} from 'sentry/utils/useSessionStorage'; +import {useNavContext} from 'sentry/views/nav/context'; +import {NavLayout} from 'sentry/views/nav/types'; +import {makeProjectsPathname} from 'sentry/views/projects/pathname'; + +interface OrganizationDropdownProps { + /** + * When true, hides settings, projects, members, teams, and billing links for the current organization. + */ + hideCurrentOrganizationLinks?: boolean; + onClick?: () => void; +} + +export function OrganizationDropdown(props: OrganizationDropdownProps) { + const navigate = useNavigate(); + const config = useLegacyStore(ConfigStore); + + const organization = useOrganization(); + const {organizations} = useLegacyStore(OrganizationsStore); + + const [activeOrgs, inactiveOrgs] = partition( + organizations.filter(org => org.slug !== organization.slug), + org => org.status.id === 'active' + ); + + const {projects} = useProjects(); + const {layout} = useNavContext(); + + const [, setReferrer] = useSessionStorage(CUSTOM_REFERRER_KEY, null); + + return ( + ( + { + triggerProps.onClick?.(e); + props.onClick?.(); + }} + > + + + )} + position="right-start" + minMenuWidth={200} + items={[ + { + key: 'organization', + label: ( + + + + ), + children: [ + ...(props.hideCurrentOrganizationLinks + ? [] + : [ + { + key: 'organization-settings', + label: t('Organization Settings'), + to: `/settings/${organization.slug}/`, + hidden: !organization.access?.includes('org:read'), + }, + { + key: 'projects', + label: t('Projects'), + onAction: () => { + setReferrer('org-dropdown'); + navigate(makeProjectsPathname({path: '/', organization})); + }, + }, + { + key: 'members', + label: t('Members'), + to: `/settings/${organization.slug}/members/`, + hidden: !organization.access?.includes('member:read'), + }, + { + key: 'teams', + label: t('Teams'), + to: `/settings/${organization.slug}/teams/`, + hidden: !organization.access?.includes('team:read'), + }, + { + key: 'billing', + label: t('Usage & Billing'), + to: `/settings/${organization.slug}/billing/`, + hidden: !organization.access?.includes('org:billing'), + }, + ]), + { + key: 'switch-organization', + label: t('Switch Organization'), + isSubmenu: true, + hidden: config.singleOrganization || isDemoModeActive(), + children: [ + { + key: 'active-orgs', + children: orderBy(activeOrgs, ['name']).map(makeOrganizationMenuItem), + // Hide entire submenu if there are no active organizations + hidden: activeOrgs.length === 0, + }, + { + key: 'inactive-ogs', + children: orderBy(inactiveOrgs, ['name']).map( + makeInactiveOrganizationMenuItem + ), + // Hide entire submenu if there are no inactive organizations + hidden: inactiveOrgs.length === 0, + }, + makeCreateOrganizationMenuItem(), + ], + }, + ], + }, + ]} + /> + ); +} + +function makeOrganizationMenuItem(org: Organization): MenuItemProps { + return { + key: org.id, + label: , + textValue: org.name, + to: resolveRoute(`/organizations/${org.slug}/issues/`, null, org), + }; +} + +function makeInactiveOrganizationMenuItem(org: Organization): MenuItemProps { + return { + ...makeOrganizationMenuItem(org), + trailingItems: , + }; +} + +function makeCreateOrganizationMenuItem(): MenuItemProps { + const configFeatures = ConfigStore.get('features'); + + const menuItemProps: MenuItemProps = { + key: 'create-organization', + leadingItems: , + label: t('Create a new organization'), + }; + + if (configFeatures.has('system:multi-region')) { + menuItemProps.externalHref = + localizeDomain(ConfigStore.get('links').sentryUrl) + '/organizations/new/'; + } else { + menuItemProps.to = '/organizations/new/'; + } + + return { + key: 'create-organization-section', + children: [menuItemProps], + hidden: !ConfigStore.get('features').has('organizations:create'), + }; +} + +const OrganizationDropdownTrigger = styled(Button)<{layout: NavLayout}>` + height: ${p => (p.layout === NavLayout.MOBILE ? 32 : 48)}px; + width: ${p => (p.layout === NavLayout.MOBILE ? 32 : 48)}px; + min-height: ${p => (p.layout === NavLayout.MOBILE ? 32 : 48)}px; + padding: 0; +`; + +const SectionTitleWrapper = styled('div')` + text-transform: none; + font-size: ${p => p.theme.fontSize.md}; + font-weight: ${p => p.theme.fontWeight.normal}; + color: ${p => p.theme.tokens.content.primary}; +`; diff --git a/static/app/views/nav/sidebar.tsx b/static/app/views/nav/sidebar.tsx index 1fc40058b5adb3..f26b0f82d43939 100644 --- a/static/app/views/nav/sidebar.tsx +++ b/static/app/views/nav/sidebar.tsx @@ -16,7 +16,7 @@ import { SECONDARY_SIDEBAR_WIDTH, } from 'sentry/views/nav/constants'; import {useNavContext} from 'sentry/views/nav/context'; -import {OrgDropdown} from 'sentry/views/nav/orgDropdown'; +import {OrganizationDropdown} from 'sentry/views/nav/organizationDropdown'; import {PrimaryNavigationItems} from 'sentry/views/nav/primary/index'; import {SecondarySidebar} from 'sentry/views/nav/secondary/secondarySidebar'; import {useStackedNavigationTour, useTourModal} from 'sentry/views/nav/tour/tour'; @@ -55,7 +55,7 @@ export function Sidebar() { tourIsActive={currentStepId !== null} > - + {showSuperuserWarning && ( diff --git a/static/gsApp/hooks/disabledMemberView.tsx b/static/gsApp/hooks/disabledMemberView.tsx index 9d2d5b2b284038..15696da0d9ac97 100644 --- a/static/gsApp/hooks/disabledMemberView.tsx +++ b/static/gsApp/hooks/disabledMemberView.tsx @@ -16,7 +16,7 @@ import type {Organization} from 'sentry/types/organization'; import {useApiQuery, useMutation} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import {useParams} from 'sentry/utils/useParams'; -import {OrgDropdown} from 'sentry/views/nav/orgDropdown'; +import {OrganizationDropdown} from 'sentry/views/nav/organizationDropdown'; import {UserDropdown} from 'sentry/views/nav/userDropdown'; import {sendUpgradeRequest} from 'getsentry/actionCreators/upsell'; @@ -120,7 +120,7 @@ function DisabledMemberView(props: Props) { return ( - {organization ? : null} + {organization ? : null} {}