From e2e90c0662369461f2a7b6ee8453a54129da0792 Mon Sep 17 00:00:00 2001 From: nicholas-codecov Date: Tue, 17 Oct 2023 13:43:31 -0300 Subject: [PATCH] feat: Create Team Plan Table for the Files Changed Table on the Commit Detail Page (#2309) Create new commit fetching hooks for the team plan, as well as creating a new table for files changed tab on the commit detail page for the new team plan. GH codecov/engineering-team#633 --- .../FilesChangedTab/FilesChangedTab.jsx | 21 - .../FilesChangedTab/FilesChangedTab.spec.jsx | 17 - .../FilesChangedTab/FilesChangedTab.spec.tsx | 100 ++++ .../FilesChangedTab/FilesChangedTab.tsx | 46 ++ .../FilesChangedTable.jsx | 46 +- .../FilesChangedTable.spec.jsx | 106 ++-- .../index.js | 0 .../FilesChangedTableTeam.spec.tsx | 460 +++++++++++++++ .../FilesChangedTableTeam.tsx | 279 +++++++++ .../FilesChangedTableTeam/index.ts | 1 + .../FilesChangedTab/{index.js => index.ts} | 0 .../CommitFileDiff/CommitFileDiff.jsx | 0 .../CommitFileDiff/CommitFileDiff.spec.jsx | 11 + .../CommitFileDiff/index.js | 0 src/services/commit/index.js | 2 + src/services/commit/useCommitTeam.spec.tsx | 538 ++++++++++++++++++ src/services/commit/useCommitTeam.tsx | 395 +++++++++++++ .../commit/useCompareTotalsTeam.spec.tsx | 261 +++++++++ src/services/commit/useCompareTotalsTeam.tsx | 202 +++++++ src/services/tier/useTier.spec.tsx | 64 ++- src/services/tier/useTier.ts | 2 +- src/ui/FileList/FileList.css | 4 +- 22 files changed, 2435 insertions(+), 120 deletions(-) delete mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.jsx delete mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.jsx create mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.tsx create mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.tsx rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => FilesChangedTable}/FilesChangedTable.jsx (84%) rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => FilesChangedTable}/FilesChangedTable.spec.jsx (78%) rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => FilesChangedTable}/index.js (100%) create mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.spec.tsx create mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.tsx create mode 100644 src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/index.ts rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{index.js => index.ts} (100%) rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => shared}/CommitFileDiff/CommitFileDiff.jsx (100%) rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => shared}/CommitFileDiff/CommitFileDiff.spec.jsx (96%) rename src/pages/CommitDetailPage/subRoute/FilesChangedTab/{FilesChangeTable => shared}/CommitFileDiff/index.js (100%) create mode 100644 src/services/commit/useCommitTeam.spec.tsx create mode 100644 src/services/commit/useCommitTeam.tsx create mode 100644 src/services/commit/useCompareTotalsTeam.spec.tsx create mode 100644 src/services/commit/useCompareTotalsTeam.tsx diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.jsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.jsx deleted file mode 100644 index d1a88107fd..0000000000 --- a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { lazy, Suspense } from 'react' - -import Spinner from 'ui/Spinner' - -const FilesChangedTable = lazy(() => import('./FilesChangeTable')) - -const Loader = () => ( -
- -
-) - -function FilesChanged() { - return ( - }> - - - ) -} - -export default FilesChanged diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.jsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.jsx deleted file mode 100644 index 080f07f707..0000000000 --- a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react' - -import FilesChangedTab from './FilesChangedTab' - -jest.mock( - './FilesChangeTable/FilesChangedTable', - () => () => 'FilesChangedTable' -) - -describe('FilesChangedTab', () => { - it('renders commits table', async () => { - render() - - const table = await screen.findByText('FilesChangedTable') - expect(table).toBeInTheDocument() - }) -}) diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.tsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.tsx new file mode 100644 index 0000000000..c5860d6b99 --- /dev/null +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.spec.tsx @@ -0,0 +1,100 @@ +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 { TierNames } from 'services/tier' +import { useFlags } from 'shared/featureFlags' + +import FilesChangedTab from './FilesChangedTab' + +jest.mock('./FilesChangedTable', () => () => 'FilesChangedTable') +jest.mock('./FilesChangedTableTeam', () => () => 'FilesChangedTableTeam') + +jest.mock('shared/featureFlags') +const mockedUseFlags = useFlags as jest.Mock<{ multipleTiers: boolean }> + +const mockTeamTier = { + owner: { + plan: { + tierName: TierNames.TEAM, + }, + }, +} + +const mockProTier = { + owner: { + plan: { + tierName: TierNames.PRO, + }, + }, +} + +const server = setupServer() +const queryClient = new QueryClient() + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + planValue: 'team' | 'pro' + flagValue: boolean +} + +describe('FilesChangedTab', () => { + function setup({ planValue, flagValue }: SetupArgs) { + mockedUseFlags.mockReturnValue({ + multipleTiers: flagValue, + }) + + server.use( + graphql.query('OwnerTier', (req, res, ctx) => { + if (planValue === 'team') { + return res(ctx.status(200), ctx.data(mockTeamTier)) + } + + return res(ctx.status(200), ctx.data(mockProTier)) + }) + ) + } + + describe('user has pro tier', () => { + it('renders files changed table', async () => { + setup({ planValue: 'pro', flagValue: false }) + render(, { wrapper }) + + const table = await screen.findByText('FilesChangedTable') + expect(table).toBeInTheDocument() + }) + }) + + describe('user has team tier', () => { + it('renders team files changed table', async () => { + setup({ planValue: 'team', flagValue: true }) + + render(, { wrapper }) + + const table = await screen.findByText('FilesChangedTableTeam') + expect(table).toBeInTheDocument() + }) + }) +}) diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.tsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.tsx new file mode 100644 index 0000000000..32ad90658e --- /dev/null +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTab.tsx @@ -0,0 +1,46 @@ +import { lazy, Suspense } from 'react' +import { useParams } from 'react-router-dom' + +import { useTier } from 'services/tier' +import { useFlags } from 'shared/featureFlags' +import Spinner from 'ui/Spinner' + +const FilesChangedTable = lazy(() => import('./FilesChangedTable')) +const FilesChangedTableTeam = lazy(() => import('./FilesChangedTableTeam')) + +const Loader = () => ( +
+ +
+) + +interface URLParams { + provider: string + owner: string +} + +function FilesChanged() { + const { provider, owner } = useParams() + + const { multipleTiers } = useFlags({ + multipleTiers: false, + }) + + const { data: tierData } = useTier({ provider, owner }) + + if (tierData === 'team' && multipleTiers) { + return ( + }> + + + ) + } + + return ( + }> + + + ) +} + +export default FilesChanged diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/FilesChangedTable.jsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTable/FilesChangedTable.jsx similarity index 84% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/FilesChangedTable.jsx rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTable/FilesChangedTable.jsx index f901c140cd..9995bf7030 100644 --- a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/FilesChangedTable.jsx +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTable/FilesChangedTable.jsx @@ -2,7 +2,7 @@ import cs from 'classnames' import isEmpty from 'lodash/isEmpty' import isNumber from 'lodash/isNumber' import PropTypes from 'prop-types' -import { lazy, Suspense, useMemo } from 'react' +import { lazy, Suspense } from 'react' import { useParams } from 'react-router-dom' import Table from 'old_ui/Table' @@ -12,17 +12,22 @@ import Icon from 'ui/Icon' import Spinner from 'ui/Spinner' import TotalsNumber from 'ui/TotalsNumber' -const CommitFileDiff = lazy(() => import('./CommitFileDiff')) +const CommitFileDiff = lazy(() => import('../shared/CommitFileDiff')) const getFileData = (row, commit) => { const headCov = row?.headCoverage?.coverage const patchCov = row?.patchCoverage?.coverage const baseCov = row?.baseCoverage?.coverage - const change = - isNumber(headCov) && isNumber(baseCov) ? headCov - baseCov : Number.NaN + let change = Number.NaN + if (isNumber(headCov) && isNumber(baseCov)) { + change = headCov - baseCov + } - const hasData = isNumber(headCov) || isNumber(patchCov) + let hasData = false + if (isNumber(headCov) || isNumber(patchCov)) { + hasData = true + } return { headCoverage: headCov, @@ -89,15 +94,8 @@ const table = [ ] function createTable({ tableData }) { - if (tableData?.length <= 0) { - return [{ name: null, coverage: null, patch: null, change: null }] - } - - return tableData?.map((row) => { - const { headName, headCoverage, hasData, change, patchCoverage, commit } = - row - - return { + return tableData?.map( + ({ headName, headCoverage, hasData, change, patchCoverage, commit }) => ({ name: (
No data ), - } - }) + }) + ) } const Loader = () => ( @@ -163,16 +161,16 @@ function FilesChangedTable() { const commit = commitData?.commit const filesChanged = commit?.compareWithParent?.impactedFiles - const formattedData = useMemo( - () => filesChanged?.map((row) => getFileData(row, commit)), - [filesChanged, commit] - ) - const tableContent = createTable({ tableData: formattedData }) - - if (isLoading || commit?.state === 'pending') return + if (isLoading || commit?.state === 'pending') { + return + } - if (isEmpty(filesChanged)) + if (isEmpty(filesChanged)) { return

No files covered by tests were changed

+ } + + const formattedData = filesChanged?.map((row) => getFileData(row, commit)) + const tableContent = createTable({ tableData: formattedData }) return ( () => 'CommitFileDiff') +jest.mock('../shared/CommitFileDiff', () => () => 'CommitFileDiff') + +const mockCommitData = ({ data, state }) => ({ + owner: { + repository: { + __typename: 'Repository', + commit: { + totals: { + coverage: 100, + }, + state, + commitid: '123', + pullId: 1, + branchName: null, + createdAt: null, + author: null, + uploads: null, + message: null, + ciPassed: null, + parent: null, + compareWithParent: { + __typename: 'Comparison', + state: 'processed', + indirectChangedFilesCount: 2, + directChangedFilesCount: 2, + patchTotals: null, + impactedFiles: data, + }, + }, + }, + }, +}) +const server = setupServer() const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -18,14 +50,19 @@ const queryClient = new QueryClient({ }, }, }) -const server = setupServer() -beforeAll(() => server.listen()) +beforeAll(() => { + server.listen() +}) + beforeEach(() => { server.resetHandlers() queryClient.clear() }) -afterAll(() => server.close()) + +afterAll(() => { + server.close() +}) const wrapper = ({ children }) => ( @@ -45,41 +82,9 @@ const wrapper = ({ children }) => ( describe('FilesChangedTable', () => { function setup(data = [], state = 'processed') { server.use( - graphql.query('Commit', (req, res, ctx) => - res( - ctx.status(200), - ctx.data({ - owner: { - repository: { - __typename: 'Repository', - commit: { - totals: { - coverage: 100, - }, - state, - commitid: '123', - pullId: 1, - branchName: null, - createdAt: null, - author: null, - uploads: null, - message: null, - ciPassed: null, - parent: null, - compareWithParent: { - __typename: 'Comparison', - state: 'processed', - indirectChangedFilesCount: 2, - directChangedFilesCount: 2, - patchTotals: null, - impactedFiles: data, - }, - }, - }, - }, - }) - ) - ) + graphql.query('Commit', (req, res, ctx) => { + return res(ctx.status(200), ctx.data(mockCommitData({ data, state }))) + }) ) } @@ -245,4 +250,27 @@ describe('FilesChangedTable', () => { expect(commitFileDiff).toBeInTheDocument() }) }) + + describe('when state is pending', () => { + beforeEach(() => { + setup( + [ + { + headName: '', + baseCoverage: null, + headCoverage: null, + patchCoverage: null, + }, + ], + 'pending' + ) + }) + + it('renders spinner', async () => { + render(, { wrapper }) + + const spinner = await screen.findByTestId('spinner') + expect(spinner).toBeInTheDocument() + }) + }) }) diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/index.js b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTable/index.js similarity index 100% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/index.js rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTable/index.js diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.spec.tsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.spec.tsx new file mode 100644 index 0000000000..3f4e144b11 --- /dev/null +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.spec.tsx @@ -0,0 +1,460 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' + +import { + OrderingDirection, + OrderingParameter, +} from 'services/commit/useCommitTeam' + +import FilesChangedTableTeam, { getFilter } from './FilesChangedTableTeam' + +jest.mock('../shared/CommitFileDiff', () => () => 'CommitFileDiff') + +const mockComparisonLiteData = { + owner: { + repository: { + __typename: 'Repository', + commit: { + compareWithParent: { + __typename: 'Comparison', + state: 'processed', + patchTotals: { + coverage: 100, + }, + impactedFiles: [ + { headName: 'src/App.tsx', patchCoverage: { coverage: 100 } }, + ], + }, + }, + }, + }, +} + +const mockCommitLiteData = { + owner: { + repository: { + __typename: 'Repository', + commit: { + branchName: null, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + pullId: 10, + createdAt: '2020-08-25T16:35:32', + author: { + username: 'febg', + }, + state: 'processed', + uploads: null, + message: 'paths test', + ciPassed: true, + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + indirectChangedFilesCount: 1, + directChangedFilesCount: 1, + patchTotals: { + coverage: 100, + }, + impactedFiles: [ + { + headName: 'src/App.jsx', + missesCount: 0, + patchCoverage: { + coverage: 100, + }, + }, + { + headName: 'src/File.jsx', + missesCount: 5, + patchCoverage: { + coverage: null, + }, + }, + ], + }, + parent: { + commitid: 'd773f5bc170caec7f6e64420b0967e7bac978a8f', + totals: { + coverage: 38.30846, + }, + }, + }, + }, + }, +} + +const mockPendingCommit = { + owner: { + repository: { + __typename: 'Repository', + commit: { + branchName: null, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + pullId: 10, + createdAt: '2020-08-25T16:35:32', + author: { + username: 'febg', + }, + state: 'pending', + uploads: null, + message: 'paths test', + ciPassed: true, + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + indirectChangedFilesCount: 1, + directChangedFilesCount: 1, + patchTotals: { + coverage: 100, + }, + impactedFiles: [], + }, + parent: { + commitid: 'd773f5bc170caec7f6e64420b0967e7bac978a8f', + totals: { + coverage: 38.30846, + }, + }, + }, + }, + }, +} + +const mockPendingComparison = { + owner: { + repository: { + __typename: 'Repository', + commit: { + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + patchTotals: { + coverage: 100, + }, + impactedFiles: [], + }, + }, + }, + }, +} + +const mockEmptyFilesCommit = { + owner: { + repository: { + __typename: 'Repository', + commit: { + branchName: null, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + pullId: 10, + createdAt: '2020-08-25T16:35:32', + author: { + username: 'febg', + }, + state: 'completed', + uploads: null, + message: 'paths test', + ciPassed: true, + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + indirectChangedFilesCount: 1, + directChangedFilesCount: 1, + patchTotals: { + coverage: 100, + }, + impactedFiles: [], + }, + parent: { + commitid: 'd773f5bc170caec7f6e64420b0967e7bac978a8f', + totals: { + coverage: 38.30846, + }, + }, + }, + }, + }, +} + +const mockEmptyFilesComparison = { + owner: { + repository: { + __typename: 'Repository', + commit: { + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + patchTotals: { + coverage: 100, + }, + impactedFiles: [], + }, + }, + }, + }, +} + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +beforeAll(() => { + server.listen() +}) + +beforeEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + pendingCommit?: boolean + noCoveredFiles?: boolean +} + +describe('FilesChangedTableTeam', () => { + function setup( + { pendingCommit, noCoveredFiles }: SetupArgs = { + pendingCommit: false, + noCoveredFiles: false, + } + ) { + const user = userEvent.setup() + const mockVars = jest.fn() + + server.use( + graphql.query('GetCommitTeam', (req, res, ctx) => { + mockVars(req.variables?.filters) + + if (pendingCommit) { + return res(ctx.status(200), ctx.data(mockPendingCommit)) + } + + if (noCoveredFiles) { + return res(ctx.status(200), ctx.data(mockEmptyFilesCommit)) + } + + return res(ctx.status(200), ctx.data(mockCommitLiteData)) + }), + graphql.query('GetCompareTotalsTeam', (req, res, ctx) => { + mockVars(req.variables) + + if (pendingCommit) { + return res(ctx.status(200), ctx.data(mockPendingComparison)) + } + + if (noCoveredFiles) { + return res(ctx.status(200), ctx.data(mockEmptyFilesComparison)) + } + + return res(ctx.status(200), ctx.data(mockComparisonLiteData)) + }) + ) + + return { user, mockVars } + } + + describe('renders header', () => { + it('renders name column', async () => { + setup() + render(, { wrapper }) + + const nameHeader = await screen.findByText('Name') + expect(nameHeader).toBeInTheDocument() + }) + + it('renders missed lines column', async () => { + setup() + render(, { wrapper }) + + const nameHeader = await screen.findByText('Missed lines') + expect(nameHeader).toBeInTheDocument() + }) + + it('renders patch % column', async () => { + setup() + render(, { wrapper }) + + const nameHeader = await screen.findByText('Patch %') + expect(nameHeader).toBeInTheDocument() + }) + }) + + describe('renders data rows', () => { + it('renders name column', async () => { + setup() + render(, { wrapper }) + + await expect(await screen.findByText('src/App.jsx')).toBeTruthy() + + const path = screen.getByText('src/App.jsx') + expect(path).toBeInTheDocument() + }) + + it('renders missed lines column', async () => { + setup() + render(, { wrapper }) + + await expect(await screen.findByText('0')).toBeTruthy() + + const missesCount = screen.getByText('0') + expect(missesCount).toBeInTheDocument() + }) + + it('renders patch % column', async () => { + setup() + render(, { wrapper }) + + await expect(await screen.findByText('100.00%')).toBeTruthy() + + const path = screen.getByText('100.00%') + expect(path).toBeInTheDocument() + }) + }) + + describe('commit is pending', () => { + it('renders spinner', async () => { + setup({ pendingCommit: true }) + render(, { wrapper }) + + await waitFor(() => expect(queryClient.isFetching()).toBeGreaterThan(0)) + await waitFor(() => expect(queryClient.isFetching()).toBe(0)) + + const spinner = await screen.findByTestId('spinner') + expect(spinner).toBeInTheDocument() + }) + }) + + describe('no files were changed', () => { + it('renders no file covered message', async () => { + setup({ noCoveredFiles: true }) + render(, { wrapper }) + + const noFiles = await screen.findByText( + 'No files covered by tests were changed' + ) + expect(noFiles).toBeInTheDocument() + }) + }) + + describe('expanding file diffs', () => { + it('renders commit file diff', async () => { + const { user } = setup({}) + render(, { wrapper }) + + expect(await screen.findByTestId('file-diff-expand')).toBeTruthy() + const expander = screen.getByTestId('file-diff-expand') + expect(expander).toBeInTheDocument() + await user.click(expander) + + const commitFileDiff = await screen.findByText('CommitFileDiff') + expect(commitFileDiff).toBeInTheDocument() + }) + }) +}) + +describe('getFilter', () => { + describe('passed array is empty', () => { + it('returns undefined', () => { + const data = getFilter([]) + + expect(data).toBeUndefined() + }) + }) + + describe('id is name', () => { + describe('desc is true', () => { + it('returns id name, desc direction', () => { + const data = getFilter([{ id: 'name', desc: true }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.desc, + parameter: OrderingParameter.FILE_NAME, + }) + }) + }) + + describe('desc is false', () => { + it('returns id name, asc direction', () => { + const data = getFilter([{ id: 'name', desc: false }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.asc, + parameter: OrderingParameter.FILE_NAME, + }) + }) + }) + }) + + describe('id is missedLines', () => { + describe('desc is true', () => { + it('returns id missed lines, desc direction', () => { + const data = getFilter([{ id: 'missedLines', desc: true }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.desc, + parameter: OrderingParameter.MISSES_COUNT, + }) + }) + }) + + describe('desc is false', () => { + it('returns id missed lines, asc direction', () => { + const data = getFilter([{ id: 'missedLines', desc: false }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.asc, + parameter: OrderingParameter.MISSES_COUNT, + }) + }) + }) + }) + + describe('id is patchPercentage', () => { + describe('desc is true', () => { + it('returns id patchPercentage, desc direction', () => { + const data = getFilter([{ id: 'patchPercentage', desc: true }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.desc, + parameter: OrderingParameter.PATCH_COVERAGE, + }) + }) + }) + + describe('desc is false', () => { + it('returns id patch percentage, asc direction', () => { + const data = getFilter([{ id: 'name', desc: false }]) + + expect(data).toStrictEqual({ + direction: OrderingDirection.asc, + parameter: OrderingParameter.FILE_NAME, + }) + }) + }) + }) +}) diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.tsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.tsx new file mode 100644 index 0000000000..1f21c9f2a2 --- /dev/null +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/FilesChangedTableTeam.tsx @@ -0,0 +1,279 @@ +import { + createColumnHelper, + ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getSortedRowModel, + Row, + SortingState, + useReactTable, +} from '@tanstack/react-table' +import cs from 'classnames' +import isEmpty from 'lodash/isEmpty' +import { Fragment, lazy, Suspense, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { + ImpactedFile, + OrderingDirection, + OrderingParameter, + useCommitTeam, +} from 'services/commit/useCommitTeam' +import A from 'ui/A' +import Icon from 'ui/Icon' +import Spinner from 'ui/Spinner' +import 'ui/FileList/FileList.css' + +const CommitFileDiff = lazy(() => import('../shared/CommitFileDiff')) + +const columnHelper = createColumnHelper() + +const isNumericColumn = (cellId: string) => + cellId === 'missedLines' || cellId === 'patchPercentage' + +export function getFilter(sorting: Array<{ id: string; desc: boolean }>) { + const state = sorting.at(0) + + if (state) { + const direction = state?.desc + ? OrderingDirection.desc + : OrderingDirection.asc + + let parameter = undefined + if (state.id === 'name') { + parameter = OrderingParameter.FILE_NAME + } + + if (state.id === 'missedLines') { + parameter = OrderingParameter.MISSES_COUNT + } + + if (state.id === 'patchPercentage') { + parameter = OrderingParameter.PATCH_COVERAGE + } + + return { direction, parameter } + } + + return undefined +} + +function getColumns({ commitId }: { commitId: string }) { + return [ + columnHelper.accessor('headName', { + id: 'name', + header: 'Name', + cell: ({ getValue, row }) => { + const headName = getValue() + return ( +
+ + + + {/* @ts-expect-error */} + + {headName} + +
+ ) + }, + }), + columnHelper.accessor('missesCount', { + id: 'missedLines', + header: 'Missed lines', + cell: ({ renderValue }) => renderValue(), + }), + columnHelper.accessor('patchCoverage.coverage', { + id: 'patchPercentage', + header: 'Patch %', + cell: ({ getValue }) => { + const value = getValue() + + if ((value && !isNaN(value)) || value === 0) { + return {value?.toFixed(2)}% + } + + return <>- + }, + }), + ] +} + +function RenderSubComponent({ row }: { row: Row }) { + const path = row.original?.headName + + return ( + }> + + + ) +} + +const Loader = () => ( +
+ +
+) + +interface URLParams { + provider: string + owner: string + repo: string + commit: string +} + +export default function FilesChangedTableTeam() { + const [expanded, setExpanded] = useState({}) + const [sorting, setSorting] = useState([ + { id: 'missedLines', desc: true }, + ]) + const { provider, owner, repo, commit: commitSHA } = useParams() + + const { data: commitData, isLoading } = useCommitTeam({ + provider, + owner, + repo, + commitid: commitSHA, + filters: { + hasUnintendedChanges: false, + ordering: getFilter(sorting), + }, + }) + + let filesChanged = undefined + if (commitData?.commit?.compareWithParent?.__typename === 'Comparison') { + filesChanged = commitData?.commit?.compareWithParent?.impactedFiles + } + + const table = useReactTable({ + columns: getColumns({ commitId: commitSHA }), + data: filesChanged ?? [], + state: { + expanded, + sorting, + }, + onSortingChange: setSorting, + onExpandedChange: setExpanded, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: () => true, + }) + + if (commitData?.commit?.state === 'pending') { + return + } + + if (isEmpty(filesChanged) && !isLoading) { + return

No files covered by tests were changed

+ } + + return ( +
+
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => { + const isSorted = header.column.getIsSorted() + + return ( + + ) + })} +
+ ))} + {isLoading ? ( + + ) : ( + table.getRowModel().rows.map((row, i) => ( + +
+ {row.getVisibleCells().map((cell) => { + return ( + + ) + })} +
+
+ {row.getIsExpanded() ? : null} +
+
+ )) + )} +
+
+ ) +} diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/index.ts b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/index.ts new file mode 100644 index 0000000000..2f42faf7e2 --- /dev/null +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangedTableTeam/index.ts @@ -0,0 +1 @@ +export { default } from './FilesChangedTableTeam' diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/index.js b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/index.ts similarity index 100% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/index.js rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/index.ts diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/CommitFileDiff.jsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/CommitFileDiff.jsx similarity index 100% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/CommitFileDiff.jsx rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/CommitFileDiff.jsx diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/CommitFileDiff.spec.jsx b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/CommitFileDiff.spec.jsx similarity index 96% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/CommitFileDiff.spec.jsx rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/CommitFileDiff.spec.jsx index 6e206b37d5..f6a691da8a 100644 --- a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/CommitFileDiff.spec.jsx +++ b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/CommitFileDiff.spec.jsx @@ -67,6 +67,17 @@ const mockImpactedFile = { hitUploadIds: null, }, }, + { + baseNumber: null, + headNumber: '4', + baseCoverage: null, + headCoverage: 'H', + content: '# cool python comment', + coverageInfo: { + hitCount: null, + hitUploadIds: null, + }, + }, ], }, ], diff --git a/src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/index.js b/src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/index.js similarity index 100% rename from src/pages/CommitDetailPage/subRoute/FilesChangedTab/FilesChangeTable/CommitFileDiff/index.js rename to src/pages/CommitDetailPage/subRoute/FilesChangedTab/shared/CommitFileDiff/index.js diff --git a/src/services/commit/index.js b/src/services/commit/index.js index 37bd858f0c..10c29b3c3b 100644 --- a/src/services/commit/index.js +++ b/src/services/commit/index.js @@ -1,3 +1,5 @@ export * from './useCommitYaml' export * from './useCompareTotals' export * from './useCommit' +export * from './useCommitTeam' +export * from './useCompareTotalsTeam' diff --git a/src/services/commit/useCommitTeam.spec.tsx b/src/services/commit/useCommitTeam.spec.tsx new file mode 100644 index 0000000000..2846089253 --- /dev/null +++ b/src/services/commit/useCommitTeam.spec.tsx @@ -0,0 +1,538 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' + +import { useCommitTeam } from './useCommitTeam' + +const mockCompareData = { + owner: { + repository: { + __typename: 'Repository', + commit: { + compareWithParent: { + __typename: 'Comparison', + state: 'processed', + patchTotals: { + coverage: 100, + }, + impactedFiles: [ + { headName: 'src/App.tsx', patchCoverage: { coverage: 100 } }, + ], + }, + }, + }, + }, +} + +const mockCommitData = { + owner: { + repository: { + __typename: 'Repository', + commit: { + branchName: null, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + pullId: 10, + createdAt: '2020-08-25T16:35:32', + author: { + username: 'febg', + }, + state: 'complete', + uploads: { + edges: [ + { + node: { + id: 0, + state: 'PROCESSED', + provider: 'travis', + createdAt: '2020-08-25T16:36:19.55947400:00', + updatedAt: '2020-08-25T16:36:19.67986800:00', + flags: [], + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/30582d33-de37-4272-ad50-c4dc805802fb.txt', + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065746', + uploadType: 'UPLOADED', + errors: null, + name: 'upload name', + jobCode: null, + buildCode: null, + }, + }, + { + node: { + id: 1, + state: 'PROCESSED', + provider: 'travis', + createdAt: '2020-08-25T16:36:25.82034000:00', + updatedAt: '2020-08-25T16:36:25.85988900:00', + flags: [], + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/18b19f8d-5df6-48bd-90eb-50578ed8812f.txt', + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065763', + uploadType: 'UPLOADED', + errors: null, + name: 'upload name', + jobCode: null, + buildCode: null, + }, + }, + ], + }, + message: 'paths test', + ciPassed: true, + compareWithParent: { + __typename: 'Comparison', + state: 'pending', + indirectChangedFilesCount: 1, + directChangedFilesCount: 1, + patchTotals: { + coverage: 100, + }, + impactedFiles: [ + { + headName: 'src/App.jsx', + missesCount: 0, + patchCoverage: { + coverage: 100, + }, + }, + ], + }, + parent: { + commitid: 'd773f5bc170caec7f6e64420b0967e7bac978a8f', + totals: { + coverage: 38.30846, + }, + }, + }, + }, + }, +} + +const mockNotFoundError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'NotFoundError', + message: 'commit not found', + }, + }, +} + +const mockOwnerNotActivatedError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'OwnerNotActivatedError', + message: 'owner not activated', + }, + }, +} + +const mockNullOwner = { + owner: null, +} + +const mockUnsuccessfulParseError = {} + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + jest.useRealTimers() + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean + isNullOwner?: boolean +} + +describe('useCommitTeam', () => { + function setup({ + isNotFoundError = false, + isOwnerNotActivatedError = false, + isUnsuccessfulParseError = false, + isNullOwner = false, + }: SetupArgs) { + server.use( + graphql.query('GetCommitTeam', (req, res, ctx) => { + if (isNotFoundError) { + return res(ctx.status(200), ctx.data(mockNotFoundError)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockOwnerNotActivatedError)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } else if (isNullOwner) { + return res(ctx.status(200), ctx.data(mockNullOwner)) + } else { + return res(ctx.status(200), ctx.data(mockCommitData)) + } + }), + graphql.query('GetCompareTotalsTeam', (req, res, ctx) => { + return res(ctx.status(200), ctx.data(mockCompareData)) + }) + ) + } + + describe('when useCommit is called', () => { + describe('api returns valid response', () => { + it('returns commit info', async () => { + setup({}) + + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + }), + { + wrapper, + } + ) + + await waitFor(() => result.current.isLoading) + await waitFor(() => !result.current.isLoading) + + const expectedResult = { + commit: { + author: { + username: 'febg', + }, + branchName: null, + ciPassed: true, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + compareWithParent: { + __typename: 'Comparison', + impactedFiles: [ + { + headName: 'src/App.tsx', + patchCoverage: { + coverage: 100, + }, + }, + ], + patchTotals: { + coverage: 100, + }, + state: 'processed', + }, + createdAt: '2020-08-25T16:35:32', + message: 'paths test', + pullId: 10, + state: 'complete', + uploads: [ + { + buildCode: null, + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065746', + createdAt: '2020-08-25T16:36:19.55947400:00', + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/30582d33-de37-4272-ad50-c4dc805802fb.txt', + errors: [], + flags: [], + id: 0, + jobCode: null, + name: 'upload name', + provider: 'travis', + state: 'PROCESSED', + updatedAt: '2020-08-25T16:36:19.67986800:00', + uploadType: 'UPLOADED', + }, + { + buildCode: null, + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065763', + createdAt: '2020-08-25T16:36:25.82034000:00', + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/18b19f8d-5df6-48bd-90eb-50578ed8812f.txt', + errors: [], + flags: [], + id: 1, + jobCode: null, + name: 'upload name', + provider: 'travis', + state: 'PROCESSED', + updatedAt: '2020-08-25T16:36:25.85988900:00', + uploadType: 'UPLOADED', + }, + ], + }, + } + + await waitFor(() => expect(result.current.data).toEqual(expectedResult)) + }) + }) + + describe('there is a null owner', () => { + it('returns a null value', async () => { + setup({ isNullOwner: true }) + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.data).toStrictEqual({ + commit: null, + }) + ) + }) + }) + }) + + describe('returns NotFoundError __typename', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 404', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) + + describe('returns OwnerNotActivatedError __typename', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 403', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 403, + }) + ) + ) + }) + }) + + describe('unsuccessful parse of zod schema', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 404', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + }), + { + wrapper, + } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) +}) + +describe('useCommitTeam polling', () => { + function setup() { + let nbCallCompare = 0 + server.use( + graphql.query(`GetCommitTeam`, (req, res, ctx) => { + return res(ctx.status(200), ctx.data(mockCommitData)) + }), + graphql.query(`GetCompareTotalsTeam`, (req, res, ctx) => { + nbCallCompare++ + + if (nbCallCompare < 9) { + return res(ctx.status(200), ctx.data(mockCommitData)) + } + + return res(ctx.status(200), ctx.data(mockCompareData)) + }) + ) + } + + describe('when useCommit is called', () => { + beforeEach(async () => { + setup() + }) + + it('returns commit data merged with what polling fetched', async () => { + const { result } = renderHook( + () => + useCommitTeam({ + provider: 'gh', + owner: 'febg', + repo: 'repo-test', + commitid: 'a23sda3', + refetchInterval: 5, + }), + { + wrapper, + } + ) + + await waitFor(() => result.current.isSuccess) + await waitFor(() => { + if ( + result.current.data?.commit?.compareWithParent?.__typename === + 'Comparison' + ) { + return ( + result.current.data?.commit?.compareWithParent?.state === + 'processed' + ) + } + }) + + await waitFor(() => + expect(result.current.data).toEqual({ + commit: { + branchName: null, + commitid: 'f00162848a3cebc0728d915763c2fd9e92132408', + pullId: 10, + createdAt: '2020-08-25T16:35:32', + author: { + username: 'febg', + }, + state: 'complete', + message: 'paths test', + ciPassed: true, + compareWithParent: { + __typename: 'Comparison', + patchTotals: { + coverage: 100, + }, + state: 'processed', + impactedFiles: [ + { + headName: 'src/App.tsx', + patchCoverage: { + coverage: 100, + }, + }, + ], + }, + uploads: [ + { + buildCode: null, + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065746', + createdAt: '2020-08-25T16:36:19.55947400:00', + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/30582d33-de37-4272-ad50-c4dc805802fb.txt', + errors: [], + flags: [], + id: 0, + jobCode: null, + name: 'upload name', + provider: 'travis', + state: 'PROCESSED', + updatedAt: '2020-08-25T16:36:19.67986800:00', + uploadType: 'UPLOADED', + }, + { + buildCode: null, + ciUrl: 'https://travis-ci.com/febg/repo-test/jobs/721065763', + createdAt: '2020-08-25T16:36:25.82034000:00', + downloadUrl: + '/api/gh/febg/repo-test/download/build?path=v4/raw/2020-08-25/F84D6D9A7F883055E40E3B380280BC44/f00162848a3cebc0728d915763c2fd9e92132408/18b19f8d-5df6-48bd-90eb-50578ed8812f.txt', + errors: [], + flags: [], + id: 1, + jobCode: null, + name: 'upload name', + provider: 'travis', + state: 'PROCESSED', + updatedAt: '2020-08-25T16:36:25.85988900:00', + uploadType: 'UPLOADED', + }, + ], + }, + }) + ) + }) + }) +}) diff --git a/src/services/commit/useCommitTeam.tsx b/src/services/commit/useCommitTeam.tsx new file mode 100644 index 0000000000..4485f6df95 --- /dev/null +++ b/src/services/commit/useCommitTeam.tsx @@ -0,0 +1,395 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' + +import { + FirstPullRequestSchema, + MissingBaseCommitSchema, + MissingBaseReportSchema, + MissingComparisonSchema, + MissingHeadCommitSchema, + MissingHeadReportSchema, +} from 'services/comparison/schemas' +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo/schemas' +import Api from 'shared/api' +import { + ErrorCodeEnum, + UploadStateEnum, + UploadTypeEnum, +} from 'shared/utils/commit' +import { mapEdges } from 'shared/utils/graphql' +import A from 'ui/A' + +import { useCompareTotalsTeam } from './useCompareTotalsTeam' + +export const OrderingDirection = { + desc: 'DESC', + asc: 'ASC', +} as const + +export const OrderingParameter = { + FILE_NAME: 'FILE_NAME', + MISSES_COUNT: 'MISSES_COUNT', + PATCH_COVERAGE: 'PATCH_COVERAGE', +} as const + +const ImpactedFilesOrdering = z.object({ + direction: z.nativeEnum(OrderingDirection).optional(), + parameter: z.nativeEnum(OrderingParameter).optional(), +}) + +const CoverageObjSchema = z.object({ + coverage: z.number().nullable(), +}) + +const UploadTypeEnumSchema = z.nativeEnum(UploadTypeEnum) + +const UploadStateEnumSchema = z.nativeEnum(UploadStateEnum) + +const UploadErrorCodeEnumSchema = z.nativeEnum(ErrorCodeEnum) + +const UploadErrorSchema = z.object({ + errorCode: UploadErrorCodeEnumSchema.nullable(), +}) + +const ErrorsSchema = z.object({ + edges: z.array( + z + .object({ + node: UploadErrorSchema, + }) + .nullable() + ), +}) + +const UploadSchema = z.object({ + id: z.number().nullable(), + state: UploadStateEnumSchema, + provider: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + flags: z.array(z.string()).nullable(), + jobCode: z.string().nullable(), + downloadUrl: z.string().nullable(), + ciUrl: z.string().nullable(), + uploadType: UploadTypeEnumSchema.nullable(), + buildCode: z.string().nullable(), + name: z.string().nullable(), + errors: ErrorsSchema.nullable(), +}) + +const UploadsSchema = z.object({ + edges: z.array( + z + .object({ + node: UploadSchema, + }) + .nullable() + ), +}) + +const ImpactedFileSchema = z + .object({ + headName: z.string().nullable(), + missesCount: z.number(), + patchCoverage: CoverageObjSchema.nullable(), + }) + .nullable() + +export type ImpactedFile = z.infer + +const ComparisonSchema = z.object({ + __typename: z.literal('Comparison'), + indirectChangedFilesCount: z.number(), + directChangedFilesCount: z.number(), + state: z.string(), + patchTotals: CoverageObjSchema.nullable(), + impactedFiles: z.array(ImpactedFileSchema), +}) + +const CompareWithParentSchema = z.discriminatedUnion('__typename', [ + ComparisonSchema, + FirstPullRequestSchema, + MissingBaseCommitSchema, + MissingBaseReportSchema, + MissingComparisonSchema, + MissingHeadCommitSchema, + MissingHeadReportSchema, +]) + +const CommitSchema = z.object({ + state: z.string().nullable(), + commitid: z.string().nullable(), + pullId: z.number().nullable(), + branchName: z.string().nullable(), + createdAt: z.string().nullable(), + author: z + .object({ + username: z.string().nullable(), + }) + .nullable(), + uploads: UploadsSchema.nullable(), + message: z.string().nullable(), + ciPassed: z.boolean().nullable(), + compareWithParent: CompareWithParentSchema.nullable(), +}) + +const RepositorySchema = z.object({ + __typename: z.literal('Repository'), + commit: CommitSchema.nullable(), +}) + +const RequestSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + RepositorySchema, + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]), + }) + .nullable(), +}) + +const query = `query GetCommitTeam( + $owner: String! + $repo: String! + $commitid: String! + $filters: ImpactedFilesFilters +) { + owner(username: $owner) { + repository(name: $repo) { + __typename + ... on Repository { + commit(id: $commitid) { + state + commitid + pullId + branchName + createdAt + author { + username + } + uploads { + edges { + node { + id + state + provider + createdAt + updatedAt + flags + jobCode + downloadUrl + ciUrl + uploadType + buildCode + name + errors { + edges { + node { + errorCode + } + } + } + } + } + } + message + ciPassed + compareWithParent { + __typename + ... on Comparison { + indirectChangedFilesCount + directChangedFilesCount + state + patchTotals { + coverage: percentCovered + } + impactedFiles: impactedFilesDeprecated(filters: $filters) { + headName + missesCount + patchCoverage { + coverage: percentCovered + } + } + } + ... on FirstPullRequest { + message + } + ... on MissingBaseCommit { + message + } + ... on MissingHeadCommit { + message + } + ... on MissingComparison { + message + } + ... on MissingBaseReport { + message + } + ... on MissingHeadReport { + message + } + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +}` + +interface UseCommitTeamArgs { + provider: string + owner: string + repo: string + commitid: string + filters?: { + hasUnintendedChanges?: boolean + flags?: Array + ordering?: z.infer + } + refetchInterval?: number +} + +export function useCommitTeam({ + provider, + owner, + repo, + commitid, + filters = {}, + refetchInterval = 2000, +}: UseCommitTeamArgs) { + const queryClient = useQueryClient() + const commitKey = [ + 'GetCommitTeam', + provider, + owner, + repo, + commitid, + query, + filters, + ] + + const commitQuery = useQuery({ + queryKey: commitKey, + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + provider, + owner, + repo, + commitid, + filters, + }, + }).then((res) => { + const parsedRes = RequestSchema.safeParse(res?.data) + + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: null, + }) + } + + const data = parsedRes.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + }) + } + + if (data?.owner?.repository?.__typename === 'OwnerNotActivatedError') { + return Promise.reject({ + status: 403, + data: { + detail: ( +

+ Activation is required to view this repo, please{' '} + {/* @ts-expect-error */} + click here to activate + your account. +

+ ), + }, + }) + } + + const commit = data?.owner?.repository?.commit + const uploadEdges = data?.owner?.repository?.commit?.uploads + + const uploads = mapEdges(uploadEdges).map((upload) => { + const errors = mapEdges(upload?.errors) + + return { + ...upload, + errors: errors, + } + }) + + if (!commit) { + return { + commit: null, + } + } + return { + commit: { + ...commit, + uploads, + }, + } + }), + suspense: false, + }) + + let shouldPoll = false + if ( + commitQuery?.data?.commit?.compareWithParent?.__typename === 'Comparison' + ) { + shouldPoll = + commitQuery?.data?.commit?.compareWithParent?.state === 'pending' + } + + useCompareTotalsTeam({ + provider, + owner, + repo, + commitid, + filters, + opts: { + refetchInterval, + enabled: shouldPoll, + suspense: false, + onSuccess: (data) => { + let compareWithParent = undefined + if (data?.compareWithParent?.__typename === 'Comparison') { + compareWithParent = data?.compareWithParent + } + + const impactedFileData = { + ...commitQuery?.data, + commit: { + ...commitQuery?.data?.commit, + compareWithParent, + }, + } + queryClient.setQueryData(commitKey, impactedFileData) + }, + }, + }) + + return commitQuery +} diff --git a/src/services/commit/useCompareTotalsTeam.spec.tsx b/src/services/commit/useCompareTotalsTeam.spec.tsx new file mode 100644 index 0000000000..811e086e75 --- /dev/null +++ b/src/services/commit/useCompareTotalsTeam.spec.tsx @@ -0,0 +1,261 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' + +import { useCompareTotalsTeam } from './useCompareTotalsTeam' + +const mockCompareData = { + owner: { + repository: { + __typename: 'Repository', + commit: { + compareWithParent: { + __typename: 'Comparison', + state: 'processed', + patchTotals: { + coverage: 100, + }, + impactedFiles: [ + { headName: 'src/App.tsx', patchCoverage: { coverage: 100 } }, + ], + }, + }, + }, + }, +} + +const mockNotFoundError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'NotFoundError', + message: 'commit not found', + }, + }, +} + +const mockOwnerNotActivatedError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'OwnerNotActivatedError', + message: 'owner not activated', + }, + }, +} + +const mockNullOwner = { + owner: null, +} + +const mockUnsuccessfulParseError = {} + +const server = setupServer() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean + isNullOwner?: boolean +} + +describe('useCompareTotalsTeam', () => { + function setup({ + isNotFoundError = false, + isOwnerNotActivatedError = false, + isUnsuccessfulParseError = false, + isNullOwner = false, + }: SetupArgs) { + server.use( + graphql.query('GetCompareTotalsTeam', (req, res, ctx) => { + if (isNotFoundError) { + return res(ctx.status(200), ctx.data(mockNotFoundError)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockOwnerNotActivatedError)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } else if (isNullOwner) { + return res(ctx.status(200), ctx.data(mockNullOwner)) + } else { + return res(ctx.status(200), ctx.data(mockCompareData)) + } + }) + ) + } + + describe('when useCompareTotalsTeam is called', () => { + describe('api returns valid response', () => { + it('returns compare info', async () => { + setup({}) + const { result } = renderHook( + () => + useCompareTotalsTeam({ + provider: 'gh', + owner: 'codecov', + repo: 'test-repo', + commitid: 's2h5a6', + }), + { wrapper } + ) + + const expectedResult = { + compareWithParent: { + __typename: 'Comparison', + impactedFiles: [ + { + headName: 'src/App.tsx', + patchCoverage: { + coverage: 100, + }, + }, + ], + patchTotals: { + coverage: 100, + }, + state: 'processed', + }, + } + + await waitFor(() => + expect(result.current.data).toStrictEqual(expectedResult) + ) + }) + }) + + describe('there is a null owner', () => { + it('returns a null value', async () => { + setup({ isNullOwner: true }) + const { result } = renderHook( + () => + useCompareTotalsTeam({ + provider: 'gh', + owner: 'codecov', + repo: 'test-repo', + commitid: 's2h5a6', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.data).toBeNull()) + }) + }) + }) + + describe('returns NotFound Error __typename', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 404', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook( + () => + useCompareTotalsTeam({ + provider: 'gh', + owner: 'codecov', + repo: 'test-repo', + commitid: 's2h5a6', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ status: 404 }) + ) + ) + }) + }) + + describe('returns OwnerNotActivatedError __typename', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 403', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => + useCompareTotalsTeam({ + provider: 'gh', + owner: 'codecov', + repo: 'test-repo', + commitid: 's2h5a6', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ status: 403 }) + ) + ) + }) + }) + + describe('unsuccessful parse of zod schema', () => { + beforeEach(() => { + jest.spyOn(console, 'error') + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('throws a 404', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + useCompareTotalsTeam({ + provider: 'gh', + owner: 'codecov', + repo: 'test-repo', + commitid: 's2h5a6', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ status: 404 }) + ) + ) + }) + }) +}) diff --git a/src/services/commit/useCompareTotalsTeam.tsx b/src/services/commit/useCompareTotalsTeam.tsx new file mode 100644 index 0000000000..17add1cfc1 --- /dev/null +++ b/src/services/commit/useCompareTotalsTeam.tsx @@ -0,0 +1,202 @@ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query' +import { z } from 'zod' + +import { + FirstPullRequestSchema, + MissingBaseCommitSchema, + MissingBaseReportSchema, + MissingComparisonSchema, + MissingHeadCommitSchema, + MissingHeadReportSchema, +} from 'services/comparison/schemas' +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo/schemas' +import Api from 'shared/api' +import A from 'ui/A' + +const CoverageObjSchema = z.object({ + coverage: z.number().nullable(), +}) + +const ComparisonSchema = z.object({ + __typename: z.literal('Comparison'), + state: z.string(), + patchTotals: CoverageObjSchema.nullable(), + impactedFiles: z.array( + z.object({ + headName: z.string().nullable(), + patchCoverage: CoverageObjSchema.nullable(), + }) + ), +}) + +const CompareWithParentSchema = z + .discriminatedUnion('__typename', [ + ComparisonSchema, + FirstPullRequestSchema, + MissingBaseCommitSchema, + MissingBaseReportSchema, + MissingComparisonSchema, + MissingHeadCommitSchema, + MissingHeadReportSchema, + ]) + .nullable() + +const CommitSchema = z.object({ + compareWithParent: CompareWithParentSchema.nullable(), +}) +const RepositorySchema = z.object({ + __typename: z.literal('Repository'), + commit: CommitSchema.nullable(), +}) + +const RequestSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + RepositorySchema, + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]), + }) + .nullable(), +}) + +const query = ` +query GetCompareTotalsTeam( + $owner: String! + $repo: String! + $commitid: String! + $filters: ImpactedFilesFilters +) { + owner(username: $owner) { + repository(name: $repo) { + __typename + ... on Repository { + commit(id: $commitid) { + compareWithParent { + __typename + ... on Comparison { + state + patchTotals { + coverage: percentCovered + } + impactedFiles: impactedFilesDeprecated(filters: $filters) { + headName + patchCoverage { + coverage: percentCovered + } + } + } + ... on FirstPullRequest { + message + } + ... on MissingBaseCommit { + message + } + ... on MissingHeadCommit { + message + } + ... on MissingComparison { + message + } + ... on MissingBaseReport { + message + } + ... on MissingHeadReport { + message + } + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +}` + +interface UseCompareTotalsTeamArgs { + provider: string + owner: string + repo: string + commitid: string + filters?: {} + opts?: UseQueryOptions | null> +} + +export function useCompareTotalsTeam({ + provider, + owner, + repo, + commitid, + filters = {}, + opts, +}: UseCompareTotalsTeamArgs) { + return useQuery({ + queryKey: [ + 'GetCompareTotalsTeam', + provider, + owner, + repo, + commitid, + query, + filters, + ], + queryFn: ({ signal }) => { + return Api.graphql({ + provider, + query, + signal, + variables: { + owner, + repo, + commitid, + filters, + }, + }).then((res) => { + const parsedRes = RequestSchema.safeParse(res?.data) + + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: null, + }) + } + + const data = parsedRes.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + }) + } + + if (data?.owner?.repository?.__typename === 'OwnerNotActivatedError') { + return Promise.reject({ + status: 403, + data: { + detail: ( +

+ Activation is required to view this repo, please{' '} + {/* @ts-expect-error */} + click here to activate + your account. +

+ ), + }, + }) + } + + return data?.owner?.repository?.commit ?? null + }) + }, + ...(!!opts && opts), + }) +} diff --git a/src/services/tier/useTier.spec.tsx b/src/services/tier/useTier.spec.tsx index fd0ae8d077..c4bf0d62cb 100644 --- a/src/services/tier/useTier.spec.tsx +++ b/src/services/tier/useTier.spec.tsx @@ -14,13 +14,17 @@ const mockOwnerTier = { }, } +const mockNullOwner = { + owner: null, +} + +const mockUnsuccessfulParseError = {} + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) const server = setupServer() -const mockUnsuccessfulParseError = {} - const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) @@ -38,12 +42,22 @@ afterAll(() => { server.close() }) +interface SetupArgs { + isNullOwner?: boolean + isUnsuccessfulParseError?: boolean +} + describe('useTier', () => { - function setup({ isUnsuccessfulParseError = false }) { + function setup({ + isUnsuccessfulParseError = false, + isNullOwner = false, + }: SetupArgs) { server.use( graphql.query('OwnerTier', (req, res, ctx) => { if (isUnsuccessfulParseError) { return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } else if (isNullOwner) { + return res(ctx.status(200), ctx.data(mockNullOwner)) } else { return res(ctx.status(200), ctx.data(mockOwnerTier)) } @@ -53,20 +67,38 @@ describe('useTier', () => { describe('when useTier is called', () => { describe('api returns valid response', () => { - beforeEach(() => { - setup({}) + describe('tier field is resolved', () => { + it('returns the owners tier', async () => { + setup({}) + const { result } = renderHook( + () => + useTier({ + provider: 'gh', + owner: 'codecov', + }), + { wrapper } + ) + + await waitFor(() => result.current.isSuccess) + await waitFor(() => expect(result.current.data).toEqual('pro')) + }) }) - it('returns the owners tier', async () => { - const { result } = renderHook( - () => - useTier({ - provider: 'gh', - owner: 'codecov', - }), - { wrapper } - ) - await waitFor(() => result.current.isSuccess) - await waitFor(() => expect(result.current.data).toEqual('pro')) + + describe('parent field is resolved as null', () => { + it('returns null value', async () => { + setup({ isNullOwner: true }) + + const { result } = renderHook( + () => + useTier({ + provider: 'gh', + owner: 'codecov', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.data).toBeNull()) + }) }) }) diff --git a/src/services/tier/useTier.ts b/src/services/tier/useTier.ts index 2cfe291ffa..54ce776d6d 100644 --- a/src/services/tier/useTier.ts +++ b/src/services/tier/useTier.ts @@ -5,7 +5,7 @@ import Api from 'shared/api' export const TierNames = { BASIC: 'basic', - LITE: 'lite', + TEAM: 'team', PRO: 'pro', ENTERPRISE: 'enterprise', } as const diff --git a/src/ui/FileList/FileList.css b/src/ui/FileList/FileList.css index a7605c9193..11d74de9be 100644 --- a/src/ui/FileList/FileList.css +++ b/src/ui/FileList/FileList.css @@ -40,8 +40,8 @@ @apply text-right justify-end; } -/* */ -.filelistui [data-type='numeric'] { +/* */ +.filelistui .filelistui-row [data-type='numeric'] { @apply font-mono text-right justify-end; }