Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nav): Upgrade org dropdown to new components #86824

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions static/app/components/nav/orgDropdown.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {UserFixture} from 'sentry-fixture/user';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {OrgDropdown} from 'sentry/components/nav/orgDropdown';
import ConfigStore from 'sentry/stores/configStore';
import OrganizationsStore from 'sentry/stores/organizationsStore';

describe('OrgDropdown', function () {
const organization = OrganizationFixture({
access: ['org:read', 'member:read', 'team:read'],
});

beforeEach(() => {
ConfigStore.set('user', UserFixture());
});

it('displays org info and links', async function () {
render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

expect(screen.getByText('org-slug')).toBeInTheDocument();
expect(screen.getByText('0 Projects')).toBeInTheDocument();

expect(screen.getByRole('link', {name: 'Organization Settings'})).toHaveAttribute(
'href',
`/settings/${organization.slug}/`
);
expect(screen.getByRole('link', {name: 'Members'})).toHaveAttribute(
'href',
`/settings/${organization.slug}/members/`
);
expect(screen.getByRole('link', {name: 'Teams'})).toHaveAttribute(
'href',
`/settings/${organization.slug}/teams/`
);
});

it('displays user info and links', async function () {
render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

expect(screen.getByText('Foo Bar')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();

expect(screen.getByRole('link', {name: 'User Settings'})).toHaveAttribute(
'href',
'/settings/account/'
);
expect(screen.getByRole('link', {name: 'User Auth Tokens'})).toHaveAttribute(
'href',
'/settings/account/api/'
);
});

it('can sign out', async function () {
const mockLogout = MockApiClient.addMockResponse({
url: '/auth/',
method: 'DELETE',
});

render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

await userEvent.click(screen.getByText('Sign Out'));

await waitFor(() => {
expect(mockLogout).toHaveBeenCalled();
});
});

it('hides admin link if user is not admin', async function () {
render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

expect(screen.queryByRole('link', {name: 'Admin'})).not.toBeInTheDocument();
});

it('shows admin link if user is admin', async function () {
ConfigStore.set('user', UserFixture({isSuperuser: true}));

render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

expect(screen.getByRole('link', {name: 'Admin'})).toHaveAttribute('href', `/manage/`);
});

it('can switch orgs', async function () {
OrganizationsStore.addOrReplace(
OrganizationFixture({id: '1', name: 'Org 1', slug: 'org-1'})
);
OrganizationsStore.addOrReplace(
OrganizationFixture({id: '2', name: 'Org 2', slug: 'org-2'})
);

render(<OrgDropdown />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'}));

await userEvent.hover(screen.getByText('Switch Organization'));

expect(await screen.findByRole('link', {name: /org-1/})).toHaveAttribute(
'href',
`/organizations/org-1/issues/`
);
expect(await screen.findByRole('link', {name: /org-2/})).toHaveAttribute(
'href',
`/organizations/org-2/issues/`
);
});
});
185 changes: 185 additions & 0 deletions static/app/components/nav/orgDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import styled from '@emotion/styled';
import orderBy from 'lodash/orderBy';

import {logout} from 'sentry/actionCreators/account';
import {Button} from 'sentry/components/button';
import {OrganizationAvatar} from 'sentry/components/core/avatar/organizationAvatar';
import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
import OrganizationBadge from 'sentry/components/idBadge/organizationBadge';
import UserBadge from 'sentry/components/idBadge/userBadge';
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 {isDemoModeEnabled} from 'sentry/utils/demoMode';
import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';
import useProjects from 'sentry/utils/useProjects';
import {useUser} from 'sentry/utils/useUser';

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: <IconAdd />,
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() {
const api = useApi();

const config = useLegacyStore(ConfigStore);
const organization = useOrganization();
const user = useUser();

// 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 {organizations} = useLegacyStore(OrganizationsStore);

const {projects} = useProjects();

function handleLogout() {
logout(api);
}

return (
<DropdownMenu
trigger={props => (
<OrgDropdownTrigger
size="zero"
borderless
aria-label={t('Toggle organization menu')}
{...props}
>
<StyledOrganizationAvatar size={32} round={false} organization={organization} />
</OrgDropdownTrigger>
)}
minMenuWidth={200}
items={[
{
key: 'organization',
label: (
<SectionTitleWrapper>
<OrganizationBadge
organization={organization}
description={tn('%s Project', '%s Projects', projects.length)}
avatarSize={32}
/>
</SectionTitleWrapper>
),
children: [
{
key: 'organization-settings',
label: t('Organization Settings'),
to: `/settings/${organization.slug}/`,
hidden: !hasOrgRead,
},
{
key: 'members',
label: t('Members'),
to: `/settings/${organization.slug}/members/`,
hidden: !hasMemberRead,
},
{
key: 'teams',
label: t('Teams'),
to: `/settings/${organization.slug}/teams/`,
hidden: !hasTeamRead,
},
{
key: 'switch-organization',
label: t('Switch Organization'),
isSubmenu: true,
disabled: !organizations?.length,
hidden: config.singleOrganization || isDemoModeEnabled(),
children: [
...orderBy(organizations, ['status.id', 'name']).map(switchOrg => ({
key: switchOrg.id,
label: <OrganizationBadge organization={switchOrg} />,
textValue: switchOrg.name,
to: resolveRoute(
`/organizations/${switchOrg.slug}/issues/`,
organization,
switchOrg
),
})),
createOrganizationMenuItem(),
],
},
],
},
{
key: 'user',
label: (
<SectionTitleWrapper>
<UserBadge user={user} avatarSize={32} />
</SectionTitleWrapper>
),
textValue: t('User Summary'),
children: [
{
key: 'user-settings',
label: t('User Settings'),
to: '/settings/account/',
},
{
key: 'user-auth-tokens',
label: t('User Auth Tokens'),
to: '/settings/account/api/',
},
{
key: 'admin',
label: t('Admin'),
to: '/manage/',
hidden: !user?.isSuperuser,
},
{
key: 'signout',
label: t('Sign Out'),
onAction: handleLogout,
},
],
},
]}
/>
);
}

const OrgDropdownTrigger = styled(Button)`
height: 44px;
width: 44px;
`;

const StyledOrganizationAvatar = styled(OrganizationAvatar)`
border-radius: 6px; /* Fixes background bleeding on corners */
`;

const SectionTitleWrapper = styled('div')`
text-transform: none;
font-size: ${p => p.theme.fontSizeMedium};
font-weight: ${p => p.theme.fontWeightNormal};
color: ${p => p.theme.textColor};
`;
10 changes: 5 additions & 5 deletions static/app/components/nav/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
SECONDARY_SIDEBAR_WIDTH,
} from 'sentry/components/nav/constants';
import {useNavContext} from 'sentry/components/nav/context';
import {OrgDropdown} from 'sentry/components/nav/orgDropdown';
import {PrimaryNavigationItems} from 'sentry/components/nav/primary/index';
import {SecondarySidebar} from 'sentry/components/nav/secondarySidebar';
import {useCollapsedNav} from 'sentry/components/nav/useCollapsedNav';
import SidebarDropdown from 'sentry/components/sidebar/sidebarDropdown';
import ConfigStore from 'sentry/stores/configStore';
import HookStore from 'sentry/stores/hookStore';
import {space} from 'sentry/styles/space';
Expand All @@ -35,7 +35,7 @@ export function Sidebar() {
<Fragment>
<SidebarWrapper role="navigation" aria-label="Primary Navigation">
<SidebarHeader isSuperuser={showSuperuserWarning}>
<SidebarDropdown orientation="left" collapsed />
<OrgDropdown />
{showSuperuserWarning && (
<Hook name="component:superuser-warning" organization={organization} />
)}
Expand Down Expand Up @@ -65,7 +65,7 @@ export function Sidebar() {

const SidebarWrapper = styled('div')`
width: ${PRIMARY_SIDEBAR_WIDTH}px;
padding: ${space(2)} 0 ${space(1)} 0;
padding: ${space(1.5)} 0 ${space(1)} 0;
border-right: 1px solid ${p => p.theme.translucentGray200};
background: ${p => p.theme.surface300};
display: flex;
Expand All @@ -85,15 +85,15 @@ const SidebarHeader = styled('header')<{isSuperuser: boolean}>`
position: relative;
display: flex;
justify-content: center;
margin-bottom: ${space(1.5)};
margin-bottom: ${space(0.5)};

${p =>
p.isSuperuser &&
css`
&:before {
content: '';
position: absolute;
inset: -${space(1)} ${space(1)};
inset: 0 ${space(1)} -${space(0.5)} ${space(1)};
border-radius: ${p.theme.borderRadius};
background: ${p.theme.sidebar.superuser};
}
Expand Down
2 changes: 1 addition & 1 deletion static/gsApp/components/superuser/superuserWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const SuperuserBadge = styled(Badge)<{stackedNav: boolean}>`
${p =>
p.stackedNav &&
css`
top: -12px;
top: -8px;
left: 2px;
right: 2px;
font-size: 10px;
Expand Down
Loading