diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx index 168aac943..73632ca6b 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.flows.spec.tsx @@ -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' @@ -196,7 +196,18 @@ jest.mock('../components', () => ({ extractTableOfContents: (): [] => [], isHtmlDescriptionFormat: (): boolean => false, MarathonDashboard: (): JSX.Element =>
Challenge Activity
, - OpportunityPagination: (): JSX.Element =>
Pagination
, + OpportunityPagination: (props: { + onPageChange: (page: number) => void + page: number + totalPages: number + }): JSX.Element => ( +
+ Pagination + {props.page < props.totalPages && ( + + )} +
+ ), ReportIssueModal: (): JSX.Element => <>, SubmissionArtifactsModal: (props: { open: boolean @@ -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(), @@ -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' } diff --git a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx index b602068b2..637bd0f72 100644 --- a/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx @@ -67,6 +67,7 @@ import { } from '../models' import { deleteChallengeSubmission, + getAllChallengeSubmissions, getChallengeAiReviewConfig, getChallengeForumTopics, getChallengeMemberResource, @@ -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 = props => ( +const SortableColumnHeader: FC = props => (