From ddf8ef4d3b38c06b05d58aa52a838965342e1cc8 Mon Sep 17 00:00:00 2001 From: Spencer Murray <159931558+spalmurray-codecov@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:46:15 -0400 Subject: [PATCH] feat: Add org list for multi org setups on plan page (#3407) --- .../AccountOrgs/AccountOrgs.test.tsx | 264 +++++++++++++++++- .../AccountOrgs/AccountOrgs.tsx | 222 +++++++++++++++ .../useInfiniteAccountOrganizations.test.tsx | 4 +- .../hooks/useInfiniteAccountOrganizations.ts | 7 +- 4 files changed, 484 insertions(+), 13 deletions(-) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.test.tsx index 8750ca97d1..36bffde563 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.test.tsx @@ -1,5 +1,10 @@ -import { render, screen } from '@testing-library/react' -import { MemoryRouter, Route } from 'react-router' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { delay, graphql, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils' +import { MemoryRouter, Route, useLocation } from 'react-router' import AccountOrgs from './AccountOrgs' @@ -14,14 +19,146 @@ const mockAccount: Account = { }, } +const org1 = { + username: 'org1', + activatedUserCount: 7, + isCurrentUserPartOfOrg: true, +} + +const org2 = { + username: 'org2', + activatedUserCount: 4, + isCurrentUserPartOfOrg: false, +} + +const org3 = { + username: 'org3', + activatedUserCount: 2, + isCurrentUserPartOfOrg: true, +} + +const mockPageOne = { + owner: { + account: { + organizations: { + edges: [ + { + node: org1, + }, + { + node: org2, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'asdf', + }, + }, + }, + }, +} + +const mockPageTwo = { + owner: { + account: { + organizations: { + edges: [ + { + node: org3, + }, + { + node: null, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, +} + +const mockReversedOrgs = { + owner: { + account: { + organizations: { + edges: [ + { + node: org3, + }, + { + node: org2, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'zzzz', + }, + }, + }, + }, +} + +let testLocation: ReturnType + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + suspense: true, + }, + }, +}) const wrapper: React.FC = ({ children }) => ( - - {children} - + + + {children} + { + testLocation = location + return null + }} + /> + + ) +const server = setupServer() + +beforeAll(() => server.listen()) +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) +afterAll(() => server.close()) + describe('AccountOrgs', () => { + function setup() { + mockAllIsIntersecting(false) + server.use( + graphql.query('InfiniteAccountOrganizations', async (info) => { + if (info.variables.direction === 'DESC') { + return HttpResponse.json({ + data: mockReversedOrgs, + }) + } + if (info.variables.after) { + await delay(100) // for testing the spinner + return HttpResponse.json({ + data: mockPageTwo, + }) + } + return HttpResponse.json({ + data: mockPageOne, + }) + }) + ) + } + it('renders Header', async () => { + setup() render(, { wrapper }) const header = await screen.findByText('Account details') @@ -33,6 +170,7 @@ describe('AccountOrgs', () => { }) it('renders total orgs', async () => { + setup() render(, { wrapper }) const label = await screen.findByText('Total organizations') @@ -43,6 +181,7 @@ describe('AccountOrgs', () => { }) it('renders total seats', async () => { + setup() render(, { wrapper }) const label = await screen.findByText('Total seats') @@ -53,6 +192,7 @@ describe('AccountOrgs', () => { }) it('renders seats remaining', async () => { + setup() render(, { wrapper }) const label = await screen.findByText('Seats remaining') @@ -63,4 +203,118 @@ describe('AccountOrgs', () => { ) expect(number).toBeInTheDocument() }) + + describe('organization list', () => { + it('renders column headers', async () => { + setup() + render(, { wrapper }) + + const nameHeader = await screen.findByText('Organization name') + expect(nameHeader).toBeInTheDocument() + const membersHeader = await screen.findByText('Activated members') + expect(membersHeader).toBeInTheDocument() + }) + + it('renders column data', async () => { + setup() + render(, { wrapper }) + + const org1 = await screen.findByText('org1') + expect(org1).toBeInTheDocument() + const org1Members = await screen.findByRole('cell', { name: '7' }) + expect(org1Members).toBeInTheDocument() + const org2 = await screen.findByText('org2') + expect(org2).toBeInTheDocument() + const org2Members = await screen.findByRole('cell', { name: '4' }) + expect(org2Members).toBeInTheDocument() + }) + + it('renders not in org message', async () => { + setup() + render(, { wrapper }) + + const notInOrg = await screen.findByText('Not a member') + expect(notInOrg).toBeInTheDocument() + }) + + describe('when another page of data is available', () => { + describe('and bottom of table is visible', () => { + it('fetches next page of data', async () => { + setup() + render(, { wrapper }) + + const org1 = await screen.findByText('org1') + expect(org1).toBeInTheDocument() + let org3 = screen.queryByText('org3') + expect(org3).not.toBeInTheDocument() + let org3Members = screen.queryByRole('cell', { name: '2' }) + expect(org3Members).not.toBeInTheDocument() + + mockAllIsIntersecting(true) + + org3 = await screen.findByText('org3') + expect(org3).toBeInTheDocument() + org3Members = await screen.findByRole('cell', { name: '2' }) + expect(org3Members).toBeInTheDocument() + }) + }) + }) + + describe('when user clicks on the Org name header', () => { + it('changes the ordering direction', async () => { + setup() + render(, { wrapper }) + const user = userEvent.setup() + + const header = await screen.findByText('Organization name') + expect(header).toBeInTheDocument() + + let org1: HTMLElement | null = await screen.findByText('org1') + expect(org1).toBeInTheDocument() + + await user.click(header) + + const org3 = await screen.findByText('org3') + expect(org3).toBeInTheDocument() + org1 = screen.queryByText('org1') + expect(org1).not.toBeInTheDocument() + }) + }) + + describe('when user clicks on activated user count', () => { + describe('and they are a member of that org', () => { + it('redirects them to the member page for that org', async () => { + setup() + const user = userEvent.setup() + render(, { wrapper }) + + const org1Members = await screen.findByRole('link', { name: '7' }) + expect(org1Members).toBeInTheDocument() + + await user.click(org1Members) + + await waitFor(() => + expect(testLocation.pathname).toBe('/members/gh/org1') + ) + }) + }) + + describe('and they are not a member of that org', () => { + it('does nothing', async () => { + setup() + const user = userEvent.setup() + render(, { wrapper }) + + const org2Members = await screen.findByRole('cell', { name: '4' }) + expect(org2Members).toBeInTheDocument() + + await user.click(org2Members) + + await waitFor(() => + expect(testLocation.pathname).toBe('/plan/gh/codecov') + ) + }) + }) + }) + }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.tsx index 8aa774c7af..00f5283607 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/AccountOrgs/AccountOrgs.tsx @@ -1,13 +1,168 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + SortingState, + useReactTable, +} from '@tanstack/react-table' +import { useEffect, useMemo, useState } from 'react' +import { useInView } from 'react-intersection-observer' +import { useParams } from 'react-router' + import A from 'ui/A' import { Card } from 'ui/Card' +import Icon from 'ui/Icon' +import Spinner from 'ui/Spinner' +import { Tooltip } from 'ui/Tooltip' import { Account } from '../hooks/useEnterpriseAccountDetails' +import { useInfiniteAccountOrganizations } from '../hooks/useInfiniteAccountOrganizations' interface AccountOrgsArgs { account: Account } +interface AccountOrgRow { + name: string + activatedUserCount: number + isCurrentUserPartOfOrg: boolean +} + +const Loader = () => ( +
+ +
+) + +const ActivatedUsersTooltip = () => ( + + + + + + + +

+ This includes users
+ who are activated in
+ multiple organizations. +

+ +
+
+
+
+) + +const columnHelper = createColumnHelper() + +const columns = [ + columnHelper.accessor('name', { + id: 'name', + header: 'Organization name', + cell: (info) => + info.row.original.isCurrentUserPartOfOrg ? ( + info.renderValue() + ) : ( +
+ {info.renderValue()} + + Not a member + +
+ ), + }), + columnHelper.accessor('activatedUserCount', { + id: 'activatedUserCount', + enableSorting: false, + header: () => ( +
+

Activated members

+ +
+ ), + cell: (info) => + info.row.original.isCurrentUserPartOfOrg ? ( +
+ {/* @ts-ignore-error */} + + {info.cell.renderValue()} + +
+ ) : ( +

{info.renderValue()}

+ ), + }), +] + +interface URLParams { + provider: string + owner: string +} + export default function AccountOrgs({ account }: AccountOrgsArgs) { + const { provider, owner } = useParams() + const { ref, inView } = useInView() + const [sorting, setSorting] = useState([ + { + id: 'name', + desc: false, + }, + ]) + + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + useInfiniteAccountOrganizations({ + provider, + owner, + first: 20, + orderingDirection: sorting[0]?.desc ? 'DESC' : 'ASC', + }) + + const accountOrgs: AccountOrgRow[] = useMemo(() => { + if (!data) return [] + + return data.pages.flatMap((page) => + page.organizations.flatMap((org) => { + if (!org) return [] + return [ + { + name: org.username, + activatedUserCount: org.activatedUserCount, + isCurrentUserPartOfOrg: org.isCurrentUserPartOfOrg, + } as AccountOrgRow, + ] + }) + ) + }, [data]) + + const table = useReactTable({ + columns, + data: accountOrgs, + state: { + sorting, + }, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + manualSorting: true, + }) + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage() + } + }, [accountOrgs, inView, hasNextPage, fetchNextPage]) + return ( @@ -33,6 +188,73 @@ export default function AccountOrgs({ account }: AccountOrgsArgs) {

+ +
+ + + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isLoading ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + {isFetchingNextPage ? ( + + + + ) : null} + {hasNextPage ? : null} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + + +
+
+ +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ +
+
+
) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.test.tsx index adaea68d89..26d03402f8 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.test.tsx @@ -72,8 +72,8 @@ const queryClient = new QueryClient({ }) const wrapper: React.FC = ({ children }) => ( - - {children} + + {children} ) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.ts index cf1d510e48..a5c0efee0c 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.ts +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/hooks/useInfiniteAccountOrganizations.ts @@ -39,7 +39,6 @@ const query = `query InfiniteAccountOrganizations( $owner: String!, $after: String, $first: Int!, - $ordering: AccountOrganizationOrdering!, $direction: OrderingDirection! ) { owner(username: $owner) { @@ -47,7 +46,6 @@ const query = `query InfiniteAccountOrganizations( organizations( first: $first after: $after - ordering: $ordering orderingDirection: $direction ) { edges { @@ -71,7 +69,6 @@ interface UseInfiniteAccountOrganizationsArgs { provider: string owner: string first?: number - ordering?: 'NAME' | 'ACTIVATED_USERS' orderingDirection?: OrderingDirection } @@ -79,13 +76,11 @@ export function useInfiniteAccountOrganizations({ provider, owner, first = 20, - ordering = 'ACTIVATED_USERS', orderingDirection = 'DESC', }: UseInfiniteAccountOrganizationsArgs) { const variables = { first, - ordering, - orderingDirection, + direction: orderingDirection, } return useInfiniteQuery({