From 56cb7d3d19e4e17e9ffb516970290bcd25fa63aa Mon Sep 17 00:00:00 2001 From: Spencer Murray <159931558+spalmurray-codecov@users.noreply.github.com> Date: Fri, 5 Jul 2024 09:19:01 -0400 Subject: [PATCH] feat: Implement new header's User dropdown (#2973) --- src/layouts/BaseLayout/BaseLayout.spec.jsx | 2 +- src/layouts/Header/Header.spec.tsx | 112 ++++++- src/layouts/Header/Header.tsx | 21 +- .../UserDropdown/UserDropdown.spec.tsx | 300 ++++++++++++++++++ .../components/UserDropdown/UserDropdown.tsx | 144 +++++++++ .../Header/components/UserDropdown/index.ts | 1 + src/layouts/LoginLayout/LoginLayout.spec.tsx | 6 +- src/layouts/OldHeader/Dropdown.tsx | 2 +- src/pages/OwnerPage/Header/Header.jsx | 2 +- .../CommitsTab/CommitsTable/Title/Title.tsx | 2 +- .../CommitsTab/CommitsTable/Title/Title.tsx | 2 +- .../PullsTab/PullsTable/Title/Title.tsx | 2 +- src/ui/Avatar/Avatar.jsx | 16 +- src/ui/ContextSwitcher/ContextSwitcher.jsx | 4 +- src/ui/List/List.stories.jsx | 2 +- 15 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 src/layouts/Header/components/UserDropdown/UserDropdown.spec.tsx create mode 100644 src/layouts/Header/components/UserDropdown/UserDropdown.tsx create mode 100644 src/layouts/Header/components/UserDropdown/index.ts diff --git a/src/layouts/BaseLayout/BaseLayout.spec.jsx b/src/layouts/BaseLayout/BaseLayout.spec.jsx index 796062d399..4f2e92dcfc 100644 --- a/src/layouts/BaseLayout/BaseLayout.spec.jsx +++ b/src/layouts/BaseLayout/BaseLayout.spec.jsx @@ -402,7 +402,7 @@ describe('BaseLayout', () => { wrapper: wrapper(), }) - const newHeader = await screen.findByText('New header') + const newHeader = await screen.findByText('Navigation') expect(newHeader).toBeInTheDocument() }) }) diff --git a/src/layouts/Header/Header.spec.tsx b/src/layouts/Header/Header.spec.tsx index 89a5c26ad0..6ae3f6ce5a 100644 --- a/src/layouts/Header/Header.spec.tsx +++ b/src/layouts/Header/Header.spec.tsx @@ -1,12 +1,114 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' + +import { User } from 'services/user' import Header from './Header' -describe('placeholder new header', () => { - it('renders', async () => { - render(
) +jest.mock( + 'src/layouts/Header/components/UserDropdown', + () => () => 'User Dropdown' +) + +const mockUser = { + me: { + owner: { + defaultOrgUsername: 'codecov', + }, + email: 'jane.doe@codecov.io', + privateAccess: true, + onboardingCompleted: true, + businessEmail: 'jane.doe@codecov.io', + termsAgreement: true, + user: { + name: 'Jane Doe', + username: 'janedoe', + avatarUrl: 'http://127.0.0.1/avatar-url', + avatar: 'http://127.0.0.1/avatar-url', + student: false, + studentCreatedAt: null, + studentUpdatedAt: null, + customerIntent: 'PERSONAL', + }, + trackingMetadata: { + service: 'github', + ownerid: 123, + serviceId: '123', + plan: 'users-basic', + staff: false, + hasYaml: false, + bot: null, + delinquent: null, + didTrial: null, + planProvider: null, + planUserCount: 1, + createdAt: 'timestamp', + updatedAt: 'timestamp', + profile: { + createdAt: 'timestamp', + otherGoal: null, + typeProjects: [], + goals: [], + }, + }, + }, +} + +const mockNullUser = { me: null } + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +beforeAll(() => server.listen()) +afterEach(() => { + server.resetHandlers() + queryClient.clear() +}) +afterAll(() => server.close()) + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +type SetupArgs = { + user?: User +} + +describe('Header', () => { + function setup({ user = mockUser }: SetupArgs) { + server.use( + graphql.query('CurrentUser', (req, res, ctx) => + res(ctx.status(200), ctx.data(user)) + ) + ) + } + + describe('placeholder new header', () => { + it('shows when currentUser is defined', async () => { + setup({}) + render(
, { wrapper }) + + const text = await screen.findByText('Navigation') + expect(text).toBeInTheDocument() + }) + }) + + describe('guest header', () => { + it('shows when currentUser is null', async () => { + setup({ user: mockNullUser }) + render(
, { wrapper }) - const header = await screen.findByText('New header') - expect(header).toBeInTheDocument() + const link = await screen.findByText('Guest header') + expect(link).toBeInTheDocument() + }) }) }) diff --git a/src/layouts/Header/Header.tsx b/src/layouts/Header/Header.tsx index 7fd61116f7..4bef8f6a20 100644 --- a/src/layouts/Header/Header.tsx +++ b/src/layouts/Header/Header.tsx @@ -1,5 +1,24 @@ +import { useUser } from 'services/user' + +import UserDropdown from './components/UserDropdown' + function Header() { - return

New header

+ const { data: currentUser } = useUser() + + if (!currentUser) { + return

Guest header

+ } + + return ( +
+
Navigation
+
+
Self hosted stuff
+
Help dropdown
+ +
+
+ ) } export default Header diff --git a/src/layouts/Header/components/UserDropdown/UserDropdown.spec.tsx b/src/layouts/Header/components/UserDropdown/UserDropdown.spec.tsx new file mode 100644 index 0000000000..2d35df3ffd --- /dev/null +++ b/src/layouts/Header/components/UserDropdown/UserDropdown.spec.tsx @@ -0,0 +1,300 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Cookies from 'js-cookie' +import { graphql, rest } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route, Switch } from 'react-router-dom' + +import config, { + COOKIE_SESSION_EXPIRY, + LOCAL_STORAGE_SESSION_TRACKING_KEY, +} from 'config' + +import { useImage } from 'services/image' + +import UserDropdown from './UserDropdown' + +const mockUser = { + me: { + owner: { + defaultOrgUsername: 'codecov', + }, + email: 'jane.doe@codecov.io', + privateAccess: true, + onboardingCompleted: true, + businessEmail: 'jane.doe@codecov.io', + termsAgreement: true, + user: { + name: 'Jane Doe', + username: 'janedoe', + avatarUrl: 'http://127.0.0.1/avatar-url', + avatar: 'http://127.0.0.1/avatar-url', + student: false, + studentCreatedAt: null, + studentUpdatedAt: null, + customerIntent: 'PERSONAL', + }, + trackingMetadata: { + service: 'github', + ownerid: 123, + serviceId: '123', + plan: 'users-basic', + staff: false, + hasYaml: false, + bot: null, + delinquent: null, + didTrial: null, + planProvider: null, + planUserCount: 1, + createdAt: 'timestamp', + updatedAt: 'timestamp', + profile: { + createdAt: 'timestamp', + otherGoal: null, + typeProjects: [], + goals: [], + }, + }, + }, +} + +jest.mock('services/image') +jest.mock('config') +jest.mock('js-cookie') + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const server = setupServer() + +const wrapper: (initialEntries?: string) => React.FC = + (initialEntries = '/gh') => + ({ children }) => + ( + + + + + {children} + + + + + ) + +beforeAll(() => { + server.listen() +}) +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) +afterAll(() => { + server.close() +}) + +describe('UserDropdown', () => { + function setup({ selfHosted } = { selfHosted: false }) { + const mockUseImage = useImage as jest.Mock + mockUseImage.mockReturnValue({ + src: 'imageUrl', + isLoading: false, + error: null, + }) + config.IS_SELF_HOSTED = selfHosted + config.API_URL = '' + const mockRemoveItem = jest.spyOn( + window.localStorage.__proto__, + 'removeItem' + ) + + server.use( + rest.post('/logout', (req, res, ctx) => res(ctx.status(205))), + graphql.query('CurrentUser', (req, res, ctx) => + res(ctx.status(200), ctx.data(mockUser)) + ) + ) + + return { + user: userEvent.setup(), + mockRemoveItem, + } + } + + describe('when rendered', () => { + beforeEach(() => setup()) + + it('renders the users avatar', () => { + render(, { + wrapper: wrapper(), + }) + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('alt', 'avatar') + }) + }) + + describe('when on GitHub', () => { + afterEach(() => { + jest.resetAllMocks() + }) + describe('when the avatar is clicked', () => { + it('shows settings link', async () => { + const { user } = setup() + render(, { + wrapper: wrapper(), + }) + + expect(screen.queryByText('Settings')).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const link = screen.getByText('Settings') + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', '/account/gh/janedoe') + }) + + it('shows sign out button', async () => { + const { user } = setup() + render(, { + wrapper: wrapper(), + }) + + expect(screen.queryByText('Sign Out')).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const link = screen.getByText('Sign Out') + expect(link).toBeVisible() + }) + + it('handles sign out', async () => { + const { user, mockRemoveItem } = setup() + + jest.spyOn(console, 'error').mockImplementation() + const removeSpy = jest.spyOn(Cookies, 'remove').mockReturnValue() + render(, { + wrapper: wrapper(), + }) + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const button = screen.getByText('Sign Out') + expect(button).toBeVisible() + await user.click(button) + + await waitFor(() => + expect(mockRemoveItem).toHaveBeenCalledWith( + LOCAL_STORAGE_SESSION_TRACKING_KEY + ) + ) + await waitFor(() => + expect(removeSpy).toHaveBeenCalledWith(COOKIE_SESSION_EXPIRY) + ) + }) + + it('shows manage app access link', async () => { + const { user } = setup() + render(, { + wrapper: wrapper(), + }) + + expect( + screen.queryByText('Install Codecov app') + ).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const link = screen.getByText('Install Codecov app') + expect(link).toBeVisible() + expect(link).toHaveAttribute( + 'href', + 'https://github.com/apps/codecov/installations/new' + ) + }) + }) + }) + describe('when not on GitHub', () => { + describe('when the avatar is clicked', () => { + it('shows settings link', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gl'), + }) + + expect(screen.queryByText('Settings')).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const link = screen.getByText('Settings') + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', '/account/gl/janedoe') + }) + + it('shows sign out button', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gl'), + }) + + expect(screen.queryByText('Sign Out')).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const link = screen.getByText('Sign Out') + expect(link).toBeVisible() + }) + + it('handles sign out', async () => { + const { user, mockRemoveItem } = setup() + + jest.spyOn(console, 'error').mockImplementation() + const removeSpy = jest.spyOn(Cookies, 'remove').mockReturnValue() + render(, { + wrapper: wrapper(), + }) + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + const button = screen.getByText('Sign Out') + expect(button).toBeVisible() + await user.click(button) + + await waitFor(() => + expect(mockRemoveItem).toHaveBeenCalledWith( + LOCAL_STORAGE_SESSION_TRACKING_KEY + ) + ) + await waitFor(() => + expect(removeSpy).toHaveBeenCalledWith(COOKIE_SESSION_EXPIRY) + ) + }) + + it('does not show manage app access link', async () => { + const { user } = setup() + render(, { + wrapper: wrapper('/gl'), + }) + + expect( + screen.queryByText('Install Codecov app') + ).not.toBeInTheDocument() + + const openSelect = screen.getByRole('combobox') + await user.click(openSelect) + + expect( + screen.queryByText('Install Codecov app') + ).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/layouts/Header/components/UserDropdown/UserDropdown.tsx b/src/layouts/Header/components/UserDropdown/UserDropdown.tsx new file mode 100644 index 0000000000..743c5d0a76 --- /dev/null +++ b/src/layouts/Header/components/UserDropdown/UserDropdown.tsx @@ -0,0 +1,144 @@ +import { useSelect } from 'downshift' +import Cookies from 'js-cookie' +import { useHistory, useParams } from 'react-router-dom' + +import config, { + COOKIE_SESSION_EXPIRY, + LOCAL_STORAGE_SESSION_TRACKING_KEY, +} from 'config' + +import { useUser } from 'services/user' +import { cn } from 'shared/utils/cn' +import { providerToName } from 'shared/utils/provider' +import Avatar from 'ui/Avatar' +import Button from 'ui/Button' +import Icon from 'ui/Icon' + +interface URLParams { + provider: string +} + +type itemProps = { + to?: toProps + hook?: string + onClick?: () => void +} + +type toProps = { + pageName: string + options?: object +} + +function UserDropdown() { + const { data: currentUser } = useUser({ + options: { + suspense: false, + }, + }) + + const { provider } = useParams() + const isGh = providerToName(provider) === 'Github' + const history = useHistory() + + const items = + !config.IS_SELF_HOSTED && isGh + ? [ + { + props: { to: { pageName: 'codecovAppInstallation' } } as itemProps, + children: 'Install Codecov app', + }, + ] + : [] + + const handleSignOut = async () => { + await fetch(`${config.API_URL}/logout`, { + method: 'POST', + credentials: 'include', + }) + localStorage.removeItem(LOCAL_STORAGE_SESSION_TRACKING_KEY) + Cookies.remove(COOKIE_SESSION_EXPIRY) + history.replace('/login') + } + + items.push( + { + props: { + to: { + pageName: 'account', + options: { owner: currentUser?.user?.username }, + }, + }, + children: 'Settings', + }, + { + props: { + onClick: handleSignOut, + hook: 'header-dropdown-sign-out', + }, + children: 'Sign Out', + } + ) + + const { + isOpen, + getToggleButtonProps, + getItemProps, + getLabelProps, + getMenuProps, + } = useSelect({ + items, + }) + + return ( +
+ + +
    + {isOpen && + items.map((item, index) => ( +
  • + {/* @ts-expect-error props might be overloaded with stuff */} + +
  • + ))} +
+
+ ) +} + +export default UserDropdown diff --git a/src/layouts/Header/components/UserDropdown/index.ts b/src/layouts/Header/components/UserDropdown/index.ts new file mode 100644 index 0000000000..f75fac7259 --- /dev/null +++ b/src/layouts/Header/components/UserDropdown/index.ts @@ -0,0 +1 @@ +export { default } from './UserDropdown' diff --git a/src/layouts/LoginLayout/LoginLayout.spec.tsx b/src/layouts/LoginLayout/LoginLayout.spec.tsx index 5309409356..4c824e1561 100644 --- a/src/layouts/LoginLayout/LoginLayout.spec.tsx +++ b/src/layouts/LoginLayout/LoginLayout.spec.tsx @@ -55,7 +55,9 @@ afterAll(() => { describe('LoginLayout', () => { function setup() { server.use( - graphql.query('CurrentUser', (req, res, ctx) => res(ctx.status(200))) + graphql.query('CurrentUser', (req, res, ctx) => + res(ctx.status(200), ctx.data({ me: null })) + ) ) mockedUseLocation.mockReturnValue({ search: [] }) mockedUseFlags.mockReturnValue({ newHeader: false }) @@ -139,7 +141,7 @@ describe('LoginLayout', () => { render(child content, { wrapper: wrapper() }) - const newHeader = await screen.findByText('New header') + const newHeader = await screen.findByText('Guest header') expect(newHeader).toBeInTheDocument() }) }) diff --git a/src/layouts/OldHeader/Dropdown.tsx b/src/layouts/OldHeader/Dropdown.tsx index 9054c06837..d613f515e1 100644 --- a/src/layouts/OldHeader/Dropdown.tsx +++ b/src/layouts/OldHeader/Dropdown.tsx @@ -105,7 +105,7 @@ function Dropdown({ currentUser }: { currentUser: CurrentUser }) { type="button" {...getToggleButtonProps()} > - +