Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */
import { PropsWithChildren } from 'react'
import { readFileSync } from 'fs'
import '@testing-library/jest-dom'
Expand Down Expand Up @@ -196,7 +196,18 @@ jest.mock('../components', () => ({
extractTableOfContents: (): [] => [],
isHtmlDescriptionFormat: (): boolean => false,
MarathonDashboard: (): JSX.Element => <div>Challenge Activity</div>,
OpportunityPagination: (): JSX.Element => <div>Pagination</div>,
OpportunityPagination: (props: {
onPageChange: (page: number) => void
page: number
totalPages: number
}): JSX.Element => (
<div>
Pagination
{props.page < props.totalPages && (
<button onClick={() => props.onPageChange(props.page + 1)} type='button'>Next page</button>
)}
</div>
),
ReportIssueModal: (): JSX.Element => <></>,
SubmissionArtifactsModal: (props: {
open: boolean
Expand Down Expand Up @@ -231,6 +242,7 @@ jest.mock('../components/challenge-card.utils', () => ({

jest.mock('../services', () => ({
deleteChallengeSubmission: (...args: unknown[]) => mockDeleteSubmission(...args),
getAllChallengeSubmissions: jest.fn(),
getChallengeAiReviewConfig: jest.fn(),
getChallengeOpportunity: jest.fn(),
getChallengeProjectResults: jest.fn(),
Expand Down Expand Up @@ -2039,6 +2051,85 @@ describe('ChallengeDetailsPage member flows', () => {
}
})

it.each([
['Handle', ['Beta', 'unknown', 'Zulu']],
['Rating', ['Zulu', 'Beta', 'unknown']],
['Provisional Score', ['Zulu', 'Beta', 'unknown']],
['Final Score', ['Beta', 'Zulu', 'unknown']],
])('sorts %s in both directions using displayed values', (label, ascending) => {
mockChallenge = { ...mockChallenge, phases: [], status: 'COMPLETED', type: 'Marathon Match' }
mockSubmissions = [
{ finalScore: 20, id: 'a', memberId: '1', provisionalScore: 2, rating: 999, submitterHandle: 'A' },
{ finalScore: 3, id: 'b', memberId: '2', provisionalScore: 10, rating: 888, submitterHandle: 'B' },
{ id: 'c', submitterHandle: 'unknown' },
]
mockMemberProfiles = [
{ handle: 'Zulu', maxRating: 2, userId: '1' },
{ handle: 'Beta', maxRating: 10, userId: '2' },
]
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
fireEvent.click(screen.getByRole('button', { name: label }))
const rowHandles = (): string[] => screen.getAllByRole('row')
.slice(1)
.map(row => within(row)
.getByRole('link').textContent ?? '')
expect(rowHandles())
.toEqual(ascending)
expect(screen.getByRole('columnheader', { name: label }))
.toHaveAttribute('aria-sort', 'ascending')
fireEvent.click(screen.getByRole('button', { name: label }))
expect(rowHandles())
.toEqual(label === 'Handle' ? [...ascending].reverse() : [ascending[1], ascending[0], 'unknown'])
expect(screen.getByRole('columnheader', { name: label }))
.toHaveAttribute('aria-sort', 'descending')
expect(screen.getByRole('columnheader', { name: 'Submission Date' }))
.toHaveAttribute('aria-sort', 'none')
})

it('keeps My Submissions on date ordering after sorting the public table by handle', () => {
mockProfile = { handle: 'coder', userId: 123 }
mockRegistration = { id: 'resource-id' }
mockChallenge = { ...mockChallenge, type: 'Marathon Match' }
mockSubmissions = [{ id: 'submission-1', memberId: '123', submitterHandle: 'coder' }]
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
fireEvent.click(screen.getByRole('button', { name: 'Handle' }))
fireEvent.click(screen.getByRole('tab', { name: 'My Submissions' }))
expect(screen.getByRole('button', { name: 'Sort by Submission Date' }))
.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sort by Handle' }))
.not.toBeInTheDocument()
})

it('sorts across the whole collection before pagination and resets the page for a different field', () => {
mockChallenge = { ...mockChallenge, type: 'Marathon Match' }
mockSubmissions = Array.from({ length: 12 }, (_value, index) => ({
id: `submission-${index}`,
rating: index,
submitterHandle: `coder${String(11 - index)
.padStart(2, '0')}`,
}))
renderPage()
fireEvent.click(screen.getByRole('tab', { name: /^Submissions/ }))
fireEvent.click(screen.getByRole('button', { name: 'Handle' }))
expect(screen.getAllByRole('row'))
.toHaveLength(11)
expect(screen.getByRole('link', { name: 'coder00' }))
.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'coder11' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
expect(screen.getAllByRole('row'))
.toHaveLength(3)
expect(screen.getByRole('link', { name: 'coder11' }))
.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Rating' }))
expect(screen.getAllByRole('row'))
.toHaveLength(11)
expect(screen.getByRole('link', { name: 'coder11' }))
.toBeInTheDocument()
})

it('renders Marathon Match testing progress and both score phases without rounding', () => {
mockProfile = { handle: 'coder', userId: 123 }
mockRegistration = { id: 'resource-id' }
Expand Down
159 changes: 127 additions & 32 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from '../models'
import {
deleteChallengeSubmission,
getAllChallengeSubmissions,
getChallengeAiReviewConfig,
getChallengeForumTopics,
getChallengeMemberResource,
Expand Down Expand Up @@ -153,17 +154,23 @@ export function challengeAllowsDesignSubmissionDeletion(
return !!requiredPhaseKey && openPhaseKeys.includes(requiredPhaseKey)
}

type SubmissionSortField = 'handle' | 'rating' | 'submittedDate' | 'provisionalScore' | 'finalScore'

interface SortableColumnHeaderProps {
label: string
onToggle: () => void
order: 'asc' | 'desc'
}

interface ActiveSortableColumnHeaderProps extends SortableColumnHeaderProps {
active?: boolean
}

/** Renders an accessible table column that toggles its owning ordering. */
const SortableColumnHeader: FC<SortableColumnHeaderProps> = props => (
const SortableColumnHeader: FC<ActiveSortableColumnHeaderProps> = props => (
<th
aria-label={props.label}
aria-sort={props.order === 'asc' ? 'ascending' : 'descending'}
aria-sort={props.active === false ? 'none' : props.order === 'asc' ? 'ascending' : 'descending'}
>
<button
aria-label={props.label}
Expand Down Expand Up @@ -1298,6 +1305,8 @@ interface SubmissionsTabProps {

/**
* Loads and paginates submissions only after a submission tab is selected.
* Derived-column sorts use the full latest-submission collection before pagination;
* submission-date sorting remains server-paginated.
*
* @param props challenge, registration/management rights, viewer identity, My Submissions flag, and callbacks.
* @returns submission table/gallery, lifecycle-aware empty state, or request state.
Expand All @@ -1307,6 +1316,8 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(10)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [allSubmissionsSortBy, setSortBy] = useState<SubmissionSortField>('submittedDate')
const sortBy = props.mine ? 'submittedDate' : allSubmissionsSortBy
const [artifactsSubmissionId, setArtifactsSubmissionId] = useState<string>()
const [historySubmission, setHistorySubmission] = useState<ChallengeSubmission | undefined>()
const [aiSubmissionExpansionOverrides, setAiSubmissionExpansionOverrides]
Expand Down Expand Up @@ -1361,18 +1372,20 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
&& !props.mine
&& challengeMetadataFlag(props.challenge, 'submissionsViewable')
const usePublicPreviewPage = privatePreviewGallery && !props.viewerMemberId
const clientSort = !props.mine && !privatePreviewGallery && !privateDesignSubmissions
&& sortBy !== 'submittedDate'
const response: SWRResponse<OpportunityPage<ChallengeSubmission>, Error> = useSWR(
privateDesignSubmissions ? undefined : [
usePublicPreviewPage ? 'opportunities:submission-previews' : 'opportunities:submissions',
props.challenge.id,
props.memberId,
page,
perPage,
sortOrder,
clientSort ? 'all' : page,
clientSort ? 'all' : perPage,
clientSort ? 'desc' : sortOrder,
],
() => (usePublicPreviewPage
? getChallengeSubmissionPreviews(props.challenge.id, page, perPage)
: getChallengeSubmissions(
: clientSort ? getAllChallengeSubmissions(props.challenge.id) : getChallengeSubmissions(
props.challenge.id,
page,
perPage,
Expand Down Expand Up @@ -1404,7 +1417,7 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
() => getChallengeReviewSummations(props.challenge.id),
{ revalidateOnFocus: false, shouldRetryOnError: false },
)
const submissions = useMemo(() => {
const loadedSubmissions = useMemo(() => {
const previewById = new Map((previewResponse.data?.items ?? [])
.map(item => [item.id, item.previewUrl]))
const items = (response.data?.items ?? []).map(item => ({
Expand All @@ -1418,10 +1431,10 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
const submissionMemberIds = useMemo(
() => (props.mine
? []
: Array.from(new Set(submissions
: Array.from(new Set(loadedSubmissions
.map(challengeSubmissionMemberId)
.filter((memberId): memberId is string => !!memberId)))),
[props.mine, submissions],
[props.mine, loadedSubmissions],
)
const profileResponse: SWRResponse<MemberProfileSummary[], Error> = useSWR(
submissionMemberIds.length
Expand All @@ -1436,8 +1449,74 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
)
const showAllSubmissionFinalScores = shouldShowFinalSubmissionScores(
props.challenge,
submissions,
loadedSubmissions,
)
const submissions = useMemo(() => {
if (!clientSort) return loadedSubmissions

/**
* Resolves a displayed value for whole-collection sorting.
* @param submission Submission being compared, with any hydrated profile.
* @returns Visible handle, rating, or released score; undefined for missing values.
*/
const valueFor = (submission: ChallengeSubmission): string | number | undefined => {
const profile = profilesById.get(challengeSubmissionMemberId(submission) ?? '')
if (sortBy === 'handle') return profile?.handle ?? submissionHandle(submission)
if (sortBy === 'rating') {
return profile?.maxRating ?? submission.submitterMaxRating
?? submission.rating ?? undefined
}

const scores = marathonSubmissionScores(submission)
if (sortBy === 'provisionalScore') return scores.provisionalScore
if (!showAllSubmissionFinalScores) return undefined
return isMarathonMatch && scores.finalScore !== undefined
? Math.max(0, scores.finalScore)
: scores.finalScore
}

return [...loadedSubmissions].sort((first, second) => {
const left = valueFor(first)
const right = valueFor(second)
// Missing or unreleased values stay last in either direction.
if (left === undefined) return right === undefined ? 0 : 1
if (right === undefined) return -1
const difference = typeof left === 'number' && typeof right === 'number'
? left - right
: String(left)
.localeCompare(String(right), undefined, { sensitivity: 'base' })
return sortOrder === 'asc' ? difference : -difference
})
.slice((page - 1) * perPage, page * perPage)
}, [clientSort, isMarathonMatch, loadedSubmissions, page, perPage, profilesById,
showAllSubmissionFinalScores, sortBy, sortOrder])

/**
* Selects a sortable data field or reverses its current direction.
* @param field Table data column; choosing a different field resets pagination.
* @returns void after updating the table's sort state.
*/
const selectSort = (field: typeof sortBy): void => {
setSortOrder(field === sortBy && sortOrder === 'asc' ? 'desc' : 'asc')
setSortBy(field)
setPage(1)
}

/**
* Renders one data header with the table's active sorting state.
* @param field Data column to sort when activated.
* @param label Human-readable column title.
* @returns Accessible sortable column header.
*/
const sortHeader = (field: typeof sortBy, label: string): JSX.Element => (
<SortableColumnHeader
active={sortBy === field}
label={label}
onToggle={() => selectSort(field)}
order={sortBy === field ? sortOrder : 'desc'}
/>
)

/**
* Toggles one submission's AI details without changing other expanded rows.
*
Expand Down Expand Up @@ -1545,10 +1624,10 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
setPerPage(value)
setPage(1)
}}
page={response.data.page}
perPage={response.data.perPage}
page={clientSort ? page : response.data.page}
perPage={clientSort ? perPage : response.data.perPage}
total={response.data.total}
totalPages={response.data.totalPages}
totalPages={clientSort ? Math.ceil(response.data.total / perPage) : response.data.totalPages}
/>
)
return (
Expand All @@ -1570,13 +1649,37 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
</a>
)}
</div>
{!props.mine && !privatePreviewGallery && (
<label className={styles.mobileTableSort}>
Sort submissions by
<select
onChange={event => selectSort(event.target.value as typeof sortBy)}
value={sortBy}
>
<option value='handle'>Handle</option>
{!isDesign && <option value='rating'>Rating</option>}
<option value='submittedDate'>Submission Date</option>
{(isMarathonMatch || isQa) && (
<>
<option value='provisionalScore'>
{isQa ? 'Initial Score' : 'Provisional Score'}
</option>
<option value='finalScore'>Final Score</option>
</>
)}
</select>
</label>
)}
{!privatePreviewGallery && (
<MobileTableSort
label='Submission Date'
onToggle={() => {
setSortOrder(value => (value === 'asc' ? 'desc' : 'asc'))
setPage(1)
}}
label={{
finalScore: 'Final Score',
handle: 'Handle',
provisionalScore: isQa ? 'Initial Score' : 'Provisional Score',
rating: 'Rating',
submittedDate: 'Submission Date',
}[sortBy]}
onToggle={() => selectSort(sortBy)}
order={sortOrder}
/>
)}
Expand Down Expand Up @@ -1850,20 +1953,12 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
>
<thead>
<tr>
<th>Handle</th>
{!isDesign && <th>Rating</th>}
<SortableColumnHeader
label='Submission Date'
onToggle={() => {
setSortOrder(value => (value === 'asc' ? 'desc' : 'asc'))
setPage(1)
}}
order={sortOrder}
/>
{isMarathonMatch && <th>Provisional Score</th>}
{isMarathonMatch && <th>Final Score</th>}
{isQa && <th>Initial Score</th>}
{isQa && <th>Final Score</th>}
{sortHeader('handle', 'Handle')}
{!isDesign && sortHeader('rating', 'Rating')}
{sortHeader('submittedDate', 'Submission Date')}
{isMarathonMatch && sortHeader('provisionalScore', 'Provisional Score')}
{isQa && sortHeader('provisionalScore', 'Initial Score')}
{(isMarathonMatch || isQa) && sortHeader('finalScore', 'Final Score')}
<th>Action</th>
</tr>
</thead>
Expand Down
Loading
Loading