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] Add a select all checkbox in the uploads card #3470

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import without from 'lodash/without'
import { useState } from 'react'
import { useEffect, useState } from 'react'

import { UploadTypeEnum } from 'shared/utils/commit'
import { formatTimeToNow } from 'shared/utils/dates'
Expand All @@ -14,6 +14,8 @@ import UploadReference from './UploadReference'

interface UploadProps {
upload: Upload
isSelected?: boolean
onSelectChange?: (isSelected: boolean) => void
}

const UploadItem = ({
Expand All @@ -29,11 +31,15 @@ const UploadItem = ({
name,
id,
},
isSelected,
onSelectChange = () => {},
}: UploadProps) => {
const [checked, setChecked] = useState(true)
const queryClient = useQueryClient()
const isCarriedForward = uploadType === UploadTypeEnum.CARRIED_FORWARD

useEffect(() => setChecked(isSelected ?? true), [isSelected])

return (
<div className="flex flex-col gap-1 border-r border-ds-gray-secondary px-4 py-2">
<div className="flex justify-between ">
Expand All @@ -42,6 +48,9 @@ const UploadItem = ({
checked={checked}
data-marketing="toggle-upload-hit-count"
onClick={() => {
onSelectChange(!checked)
setChecked(!checked)

if (checked && id != null) {
// User is unchecking
queryClient.setQueryData(
Expand All @@ -58,7 +67,6 @@ const UploadItem = ({
setChecked(!checked)
}}
/>

<UploadReference ciUrl={ciUrl} name={name} buildCode={buildCode} />
</div>
{createdAt && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -815,4 +815,162 @@ describe('UploadsCard', () => {
})
})
})
describe('select all interactor', () => {
beforeEach(() => {
setup({
uploadsProviderList: ['travis', 'circleci'],
uploadsOverview: 'uploads overview',
groupedUploads: {
travis: [
{
id: 1,
name: 'travis-upload-1',
state: 'PROCESSED',
provider: 'travis',
createdAt: '2020-08-25T16:36:19.559474+00:00',
updatedAt: '2020-08-25T16:36:19.679868+00:00',
flags: [],
downloadUrl: '/download/travis1',
ciUrl: 'https://travis-ci.com/job/1',
uploadType: 'UPLOADED',
jobCode: 'job1',
buildCode: 'build1',
errors: [],
},
{
id: 2,
name: 'travis-upload-2',
state: 'PROCESSED',
provider: 'travis',
createdAt: '2020-08-26T16:36:19.559474+00:00',
updatedAt: '2020-08-26T16:36:19.679868+00:00',
flags: [],
downloadUrl: '/download/travis2',
ciUrl: 'https://travis-ci.com/job/2',
uploadType: 'UPLOADED',
jobCode: 'job2',
buildCode: 'build2',
errors: [],
},
],
circleci: [
{
id: 3,
name: 'circleci-upload-1',
state: 'PROCESSED',
provider: 'circleci',
createdAt: '2020-08-27T16:36:19.559474+00:00',
updatedAt: '2020-08-27T16:36:19.679868+00:00',
flags: [],
downloadUrl: '/download/circleci1',
ciUrl: 'https://circleci.com/job/1',
uploadType: 'UPLOADED',
jobCode: 'job3',
buildCode: 'build3',
errors: [],
},
{
id: 4,
name: 'circleci-upload-2',
state: 'PROCESSED',
provider: 'circleci',
createdAt: '2020-08-28T16:36:19.559474+00:00',
updatedAt: '2020-08-28T16:36:19.679868+00:00',
flags: [],
downloadUrl: '/download/circleci2',
ciUrl: 'https://circleci.com/job/2',
uploadType: 'UPLOADED',
jobCode: 'job4',
buildCode: 'build4',
errors: [],
},
],
},
erroredUploads: {},
flagErrorUploads: {},
searchResults: [],
hasNoUploads: false,
})
})

it('renders the provider title', async () => {
render(<UploadsCard />, { wrapper })
expect(screen.getByText('travis')).toBeInTheDocument()
expect(screen.getByText('circleci')).toBeInTheDocument()
})

it('selects all by default', async () => {
render(<UploadsCard />, { wrapper })
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(6) // 2 providers + 4 uploads
checkboxes.forEach((checkbox) => {
expect(checkbox).toBeChecked()
})
})

it('unselects all when clicked', async () => {
const user = userEvent.setup()
render(<UploadsCard />, { wrapper })

const checkboxes = screen.getAllByRole('checkbox')
const travisCheckbox = checkboxes[0]
const travisUploadCheckbox1 = checkboxes[1]
const travisUploadCheckbox2 = checkboxes[2]

expect(travisCheckbox).toBeChecked()
expect(travisUploadCheckbox1).toBeChecked()
expect(travisUploadCheckbox2).toBeChecked()

await user.click(travisCheckbox!)

expect(travisCheckbox).not.toBeChecked()
expect(travisUploadCheckbox1).not.toBeChecked()
expect(travisUploadCheckbox2).not.toBeChecked()

// 'circleci' uploads remain checked
const circleciCheckbox = checkboxes[3]
const circleciUploadCheckbox1 = checkboxes[4]
const circleciUploadCheckbox2 = checkboxes[5]
expect(circleciCheckbox).toBeChecked()
expect(circleciUploadCheckbox1).toBeChecked()
expect(circleciUploadCheckbox2).toBeChecked()
})

it('shows an intermediate state', async () => {
const user = userEvent.setup()
render(<UploadsCard />, { wrapper })

const checkboxes = screen.getAllByRole('checkbox')
const travisCheckbox = checkboxes[0]
const travisUploadCheckbox1 = checkboxes[1]
if (travisUploadCheckbox1) {
await user.click(travisUploadCheckbox1)
}

expect(travisCheckbox).toHaveAttribute('aria-checked', 'mixed')
})

it('sets state to none when clicked on intermediate state', async () => {
const user = userEvent.setup()
render(<UploadsCard />, { wrapper })

const checkboxes = screen.getAllByRole('checkbox')
const travisCheckbox = checkboxes[0]
const travisUploadCheckbox1 = checkboxes[1]
const travisUploadCheckbox2 = checkboxes[2]

await user.click(travisUploadCheckbox1!)

expect(travisCheckbox).toHaveAttribute('aria-checked', 'mixed')

if (travisCheckbox) {
await user.click(travisCheckbox)
}

expect(travisCheckbox).not.toBeChecked()
expect(travisCheckbox).toHaveAttribute('aria-checked', 'false')
expect(travisUploadCheckbox1).not.toBeChecked()
expect(travisUploadCheckbox2).not.toBeChecked()
})
})
})
110 changes: 104 additions & 6 deletions src/pages/CommitDetailPage/CommitCoverage/UploadsCard/UploadsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCommitErrors } from 'services/commitErrors'
import { NONE } from 'shared/utils/extractUploads'
import A from 'ui/A'
import { Card } from 'ui/Card'
import Checkbox from 'ui/Checkbox'
import Icon from 'ui/Icon'
import SearchField from 'ui/SearchField'

Expand All @@ -13,12 +14,19 @@ import { useUploads } from './useUploads'

import YamlModal from '../YamlModal'

const INDETERMINATE_STATE = 'indeterminate'
export interface UploadFilters {
flagErrors: boolean
uploadErrors: boolean
searchTerm: string
}

export const SelectState = {
ALL_SELECTED: 'ALL_SELECTED',
SOME_SELECTED: 'SOME_SELECTED',
NONE_SELECTED: 'NONE_SELECTED',
} as const

function UploadsCard() {
const [showYAMLModal, setShowYAMLModal] = useState(false)
const [uploadFilters, setUploadFilters] = useState<UploadFilters>({
Expand All @@ -27,6 +35,9 @@ function UploadsCard() {
searchTerm: '',
})

const [selectedProviderSelectedUploads, setSelectedProviderSelectedUploads] =
useState<{ [key: string]: Set<number> }>({})

const {
uploadsProviderList,
uploadsOverview,
Expand All @@ -37,6 +48,73 @@ function UploadsCard() {
searchResults,
} = useUploads({ filters: uploadFilters })

const fillSelectedUploads = (provider: string) => {
const providerUploads = groupedUploads[provider]
const providerUploadsIndex = providerUploads?.map((_, i) => i)
const providerList = new Set(providerUploadsIndex)
setSelectedProviderSelectedUploads((prevState) => ({
...prevState,
[provider]: new Set(providerUploadsIndex),
}))
return providerList
}

const determineCheckboxState = (provider: string) => {
let selectedUploads
if (selectedProviderSelectedUploads[provider] === undefined) {
selectedUploads = fillSelectedUploads(provider)
} else {
selectedUploads = selectedProviderSelectedUploads[provider]
}

const totalUploads = groupedUploads[provider]?.length
if (selectedUploads === undefined || selectedUploads.size === totalUploads)
return SelectState.ALL_SELECTED
if (selectedUploads.size === 0) return SelectState.NONE_SELECTED
return SelectState.SOME_SELECTED
}

const handleSelectAllForProviderGroup = (provider: string) => {
setSelectedProviderSelectedUploads((prevState) => ({
...prevState,
[provider]:
determineCheckboxState(provider) === SelectState.NONE_SELECTED
? fillSelectedUploads(provider)
: new Set(),
}))
}

const determineCheckboxCheckedState = (title: string) => {
const currentCheckboxState = determineCheckboxState(title)
if (currentCheckboxState === SelectState.ALL_SELECTED) {
return true
} else if (currentCheckboxState === SelectState.SOME_SELECTED) {
return INDETERMINATE_STATE
}
return false
}

const onSelectChange = (
provider: string,
isSelected: boolean,
key: number
) => {
setSelectedProviderSelectedUploads((prevState) => {
const updatedSet = new Set(prevState[provider] || [])

if (isSelected) {
updatedSet.add(key)
} else {
updatedSet.delete(key)
}

return {
...prevState,
[provider]: updatedSet,
}
})
}

const { data } = useCommitErrors()

const invalidYamlError = data?.yamlErrors?.find(
Expand Down Expand Up @@ -90,13 +168,33 @@ function UploadsCard() {
))
: uploadsProviderList.map((title) => (
<Fragment key={title}>
{title !== NONE && (
<span className="sticky top-0 flex-1 border-r border-ds-gray-secondary bg-ds-gray-primary px-4 py-1 text-sm font-semibold">
{title}
</span>
)}
<span
className={`sticky top-0 flex-1 border-r border-ds-gray-secondary bg-ds-gray-primary px-4 py-1 text-sm font-semibold ${title === NONE ? 'text-ds-gray-quaternary' : ''}`}
>
<div className="flex items-center">
<Checkbox
checked={determineCheckboxCheckedState(title)}
onClick={() => handleSelectAllForProviderGroup(title)}
/>
<span className="ml-2">
{title === NONE ? 'Provider not specified' : title}
</span>
</div>
</span>
{groupedUploads[title]?.map((upload, i) => (
<UploadItem upload={upload} key={i} />
<UploadItem
upload={upload}
key={i}
isSelected={
determineCheckboxState(title) ===
SelectState.NONE_SELECTED
? false
: selectedProviderSelectedUploads[title]?.has(i)
}
onSelectChange={(isSelected: boolean) =>
onSelectChange(title, isSelected, i)
}
/>
))}
</Fragment>
))}
Expand Down
Loading
Loading