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
13 changes: 10 additions & 3 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -814,15 +814,18 @@ Review App handoff.
Artifact controls in All Submissions, My Submissions, and submission history
use the challenge's exact `COMPLETED` status for Marathon Matches. Before completion
(including cancelled challenges), contestants can list and download only their own
`example` artifact. After completion, registered contestants can download regular
`example` artifact, accepting both `example` and `<submissionId>-example` API IDs.
Downloads always use the original API ID; internal and unrelated IDs remain hidden.
After completion, registered contestants can download regular
and internal provisional/system artifacts for their own and other members' attempts.
Scores and phase end dates never unlock artifacts. Existing admin/copilot access
is retained. The dialog filters cached entries when permission changes, and Review
API independently authorizes both listing and every download. Completed MM dialogs
also offer Download all, packaging every returned artifact (including example-internal,
example, provisional-internal, and system-internal when authorized) into one ZIP.
If any download fails, no partial ZIP is saved. The artifact header shows the
submission ID and owner handle, including when opened from an older history entry.
bold Submission ID and Handle labels, including when opened from an older history entry.
Both modal tables vertically center their text and the shared folded-page artifact icons.
The Marathon Match My Submissions table reserves enough width for the complete
submission timestamp and keeps its date heading and sort icon on one line,
aligned with the dates beneath it. Score columns remain right aligned.
Expand All @@ -838,7 +841,11 @@ at that breakpoint. History requests include the selected member ID and submissi
filter. Visible Marathon Matches expose full history to all viewers, including
anonymous and unregistered visitors, through Review API. The History action shows
the API's complete submission count in parentheses when available. In Marathon
Match All Submissions, the actions stay on one line: a file icon for artifacts
Match All Submissions, proportional columns avoid a forced desktop scrollbar, rank
values align right, and long handles stay on one line with ellipsis and a full-handle
title when space is limited. Rank headings wrap only when necessary. All challenge
detail tables show a sort arrow only on the active column; other headers remain
clickable. The actions stay on one line: a file icon for artifacts
and a history icon followed by that count. Hovering shows `View Artifacts` or
`History (8 submissions)` with the actual count (or `History` when unavailable);
the buttons retain accessible names and open the existing dialogs. Artifact
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/apps/opportunities/src/assets/submission-artifact.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
line-height: 24px;
padding: 8px 16px;
text-align: left;
vertical-align: middle;
}

th {
Expand All @@ -77,6 +78,7 @@
cursor: pointer;
display: inline-flex;
padding: 0;
vertical-align: middle;

&:disabled {
cursor: not-allowed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,55 @@ describe('SubmissionArtifactsModal', () => {
.not.toBeInTheDocument()
})

it('shows the active match example with its exact submission-prefixed API ID', async () => {
const submissionId = 'YtoH-k9To2Ggxx'
const exampleId = `${submissionId}-example`
mockedGetArtifacts.mockResolvedValue([
exampleId, `${submissionId}-example-internal`, `${submissionId}-provisional-internal`,
`${submissionId}-system-internal`, 'other-submission-example', `${submissionId}-example-extra`,
])
render(
<SWRConfig value={{ provider: () => new Map() }}>
<SubmissionArtifactsModal onClose={jest.fn()} open submissionId={submissionId} />
</SWRConfig>,
)
fireEvent.click(await screen.findByRole('button', { name: `Download artifact ${exampleId}` }))
expect(screen.getAllByRole('row'))
.toHaveLength(2)
expect(screen.queryByRole('button', { name: 'Download all' })).not.toBeInTheDocument()
await waitFor(() => expect(mockedDownloadArtifact)
.toHaveBeenCalledWith(submissionId, exampleId))
await waitFor(() => expect(HTMLAnchorElement.prototype.click)
.toHaveBeenCalled())
expect((HTMLAnchorElement.prototype.click as jest.Mock).mock.instances[0].download)
.toBe(`${exampleId}.zip`)
})

it('filters cached prefixed internal artifacts when management access is removed', async () => {
const artifactIds = ['submission-id-example', 'submission-id-example-internal']
mockedGetArtifacts.mockResolvedValue(artifactIds)
const cache: Map<string, unknown> = new Map()
const { rerender }: ReturnType<typeof render> = render(
<SWRConfig value={{ provider: () => cache }}>
<SubmissionArtifactsModal
allowInternalArtifacts
onClose={jest.fn()}
open
submissionId='submission-id'
/>
</SWRConfig>,
)
await screen.findByRole('button', { name: `Download artifact ${artifactIds[1]}` })
rerender(
<SWRConfig value={{ provider: () => cache }}>
<SubmissionArtifactsModal onClose={jest.fn()} open submissionId='submission-id' />
</SWRConfig>,
)
await screen.findByRole('button', { name: `Download artifact ${artifactIds[0]}` })
expect(screen.queryByRole('button', { name: `Download artifact ${artifactIds[1]}` }))
.not.toBeInTheDocument()
})

it('loads and downloads the selected scorer artifact through Review API', async () => {
render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
Expand Down Expand Up @@ -169,6 +218,7 @@ describe('SubmissionArtifactsModal', () => {

it('shows submission metadata and saves all authorized artifacts in one complete ZIP', async () => {
const artifactIds = ['example-internal', 'example', 'provisional-internal', 'system-internal']
.map(id => `completed-submission-${id}`)
const onClose = jest.fn()
mockedGetArtifacts.mockResolvedValue(artifactIds)
mockedDownloadArtifact.mockImplementation((_submissionId, id) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getChallengeSubmissionArtifacts,
} from '../services'
import { marathonRatingColor } from '../utils/marathon-match.utils'
import { ReactComponent as DownloadArtifactIcon } from '../assets/submission-artifact-download.svg'

import modalStyles from './SubmissionHistoryModal.module.scss'
import styles from './SubmissionArtifactsModal.module.scss'
Expand Down Expand Up @@ -77,7 +78,8 @@ function saveArtifact(blob: Blob, filename: string): void {
/**
* Lists and downloads scorer-generated files for one authored submission.
*
* Contestants see only example before completion; staff retain management access.
* Contestants see only example (bare or submission-prefixed ID) before completion;
* downloads retain the exact API identifier. Staff retain management access.
* Completed matches offer one ZIP of all API-authorized artifacts.
* @param props selected submission, visibility, and close callback.
* @returns artifact dialog with loading, error, empty, and download states.
Expand Down Expand Up @@ -118,6 +120,7 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
// Also filter cached responses when completion or viewer permissions change.
const artifacts = (response.data ?? []).filter(artifactId => (
props.allowInternalArtifacts || artifactId.toLowerCase() === 'example'
|| artifactId === `${props.submissionId}-example`
))

/**
Expand Down Expand Up @@ -181,7 +184,7 @@ export const SubmissionArtifactsModal: FC<SubmissionArtifactsModalProps> = props
onClick={() => downloadArtifact(artifactId)}
type='button'
>
<IconOutline.DocumentDownloadIcon aria-hidden='true' />
<DownloadArtifactIcon aria-hidden='true' />
</button>
</Tooltip>
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
overflow-wrap: anywhere;
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}

th {
Expand Down Expand Up @@ -142,6 +143,10 @@
gap: 12px 20px;
margin-bottom: 24px;
overflow-wrap: anywhere;

strong {
font-weight: 700;
}
}

.artifactButton,
Expand All @@ -165,6 +170,7 @@
.artifactButton {
color: #008080;
padding: 6px;
vertical-align: middle;
}

.sortButton {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
marathonSubmissionScores,
} from '../utils/marathon-match.utils'
import { formatOpportunityDateTime } from '../utils/opportunity-date.utils'
import { ReactComponent as ArtifactIcon } from '../assets/submission-artifact.svg'

import styles from './SubmissionHistoryModal.module.scss'

Expand Down Expand Up @@ -232,7 +233,7 @@ export const SubmissionHistoryModal: FC<SubmissionHistoryModalProps> = props =>
onClick={() => props.onOpenArtifacts?.(submission.id)}
type='button'
>
<IconOutline.DocumentIcon aria-hidden='true' />
<ArtifactIcon aria-hidden='true' />
</button>
</Tooltip>
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,8 +731,10 @@ describe('ChallengeDetailsPage member flows', () => {

const artifacts = screen.getByRole('button', { name: 'Download submission artifacts latest-attempt' })
const history = screen.getByRole('button', { name: label as string })
expect(artifacts.textContent)
.toBe('')
expect(artifacts.children)
.toHaveLength(1)
expect(artifacts.querySelector('svg'))
.toHaveAttribute('aria-hidden', 'true')
expect(history.textContent)
.toBe(visibleCount)
expect(screen.queryByRole('tooltip'))
Expand Down Expand Up @@ -2262,6 +2264,12 @@ describe('ChallengeDetailsPage member flows', () => {
.toHaveAttribute('aria-sort', 'descending')
expect(screen.getByRole('columnheader', { name: 'Submission Date' }))
.toHaveAttribute('aria-sort', 'none')
expect(screen.getByRole('columnheader', { name: label })
.querySelector('svg'))
.toBeInTheDocument()
screen.getAllByRole('columnheader')
.filter(header => header.getAttribute('aria-sort') === 'none')
.forEach(header => expect(header.querySelector('svg')).not.toBeInTheDocument())
})

it('shows global MM ranks across date-sorted pages and supports mobile rank sorting', () => {
Expand Down
51 changes: 39 additions & 12 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,8 @@
.marathonActions {
align-items: center;
display: flex;
gap: 12px;
gap: 8px;
white-space: nowrap;

button { padding: 0; }
}
Expand Down Expand Up @@ -428,23 +429,49 @@
}

.marathonTableCard {
overflow-x: auto;
container-type: inline-size;
padding-right: 12px;
}

.marathonTable {
min-width: 1200px;

th,
td { padding-inline: 12px; }
td {
overflow-wrap: anywhere;
padding-inline: 8px;
}

th:first-child { width: 90px; }
th:nth-child(2) { width: 115px; }
th:nth-child(3) { width: 200px; }
th:nth-child(4) { width: 90px; }
th:nth-child(5) { width: 185px; }
// Reserve rating/action space, then size the remaining columns from the card content width.
// Container units resolve in fixed-layout tables, where mixed percent/pixel widths do not.
th:first-child { width: calc((100cqw - 168px) * .09); }
th:nth-child(2) { width: calc((100cqw - 168px) * .14); }
th:nth-child(3) { width: calc((100cqw - 168px) * .21); }
th:nth-child(4) { width: 64px; }
th:nth-child(5) { width: calc((100cqw - 168px) * .18); }
th:nth-child(6),
th:nth-child(7) { width: 170px; }
th:nth-child(8) { width: 140px; }
th:nth-child(7) { width: calc((100cqw - 168px) * .19); }
th:nth-child(8) { width: 104px; }

@media (min-width: 621px) {
td:first-child,
td:nth-child(2) { text-align: right; }
}

th:last-child,
td:last-child { padding-right: 0; }

.sortedHeader svg { flex-shrink: 0; }

.member {
max-width: 100%;

> a,
> .memberHandle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

.mySubmissionTable {
Expand Down
27 changes: 19 additions & 8 deletions src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
import { formatOpportunityDateTime } from '../utils/opportunity-date.utils'
import { ReactComponent as EmptyInfoIcon } from '../assets/empty-info.svg'
import { ReactComponent as SortIcon } from '../assets/sort.svg'
import { ReactComponent as ArtifactIcon } from '../assets/submission-artifact.svg'
import { ReactComponent as DeleteIcon } from '../assets/submission-delete.svg'
import medal1 from '../assets/winner-card-medal-1.svg'
import medal2 from '../assets/winner-card-medal-2.svg'
Expand Down Expand Up @@ -170,7 +171,13 @@ interface ActiveSortableColumnHeaderProps extends SortableColumnHeaderProps {
active?: boolean
}

/** Renders an accessible table column that toggles its owning ordering. */
/**
* Renders a sortable header, showing an arrow only for the active column.
* Shared by the Opportunities challenge detail tables.
* @param props label, active state, direction, and sorting callback.
* @returns keyboard-accessible header with its current aria-sort state.
* @throws Does not throw.
*/
const SortableColumnHeader: FC<ActiveSortableColumnHeaderProps> = props => (
<th
aria-label={props.label}
Expand All @@ -184,10 +191,12 @@ const SortableColumnHeader: FC<ActiveSortableColumnHeaderProps> = props => (
type='button'
>
<span aria-hidden='true'>{props.label}</span>
<IconOutline.ChevronDownIcon
aria-hidden='true'
className={props.order === 'asc' ? styles.sortAscending : undefined}
/>
{props.active !== false && (
<IconOutline.ChevronDownIcon
aria-hidden='true'
className={props.order === 'asc' ? styles.sortAscending : undefined}
/>
)}
</button>
</th>
)
Expand Down Expand Up @@ -1102,6 +1111,7 @@ function ratingClass(rating?: number): string | undefined {
/**
* Renders the compact avatar and linked handle shared by detail tables/cards.
*
* Long handles remain available in a title when compact table cells truncate them.
* @param props resolved member handle.
* @returns member identity cell; members without a usable photo get the
* handle-initial color placeholder from `MemberAvatar`.
Expand All @@ -1115,8 +1125,8 @@ const MemberHandle: FC<MemberHandleProps> = props => {
<span className={styles.member}>
<MemberAvatar className={styles.avatar} handle={handle} photoURL={props.profile?.photoURL} />
{props.link === false && !props.profile?.handle
? <span className={`${styles.memberHandle} ${handleClass ?? ''}`}>{handle}</span>
: <a className={handleClass} href={memberProfileUrl(handle)}>{handle}</a>}
? <span className={`${styles.memberHandle} ${handleClass ?? ''}`} title={handle}>{handle}</span>
: <a className={handleClass} href={memberProfileUrl(handle)} title={handle}>{handle}</a>}
</span>
)
}
Expand Down Expand Up @@ -2102,8 +2112,9 @@ const SubmissionsTab: FC<SubmissionsTabProps> = props => {
onClick={() => openArtifacts(submission.id)}
type='button'
>
<IconOutline.DocumentIcon
<ArtifactIcon
aria-hidden='true'
height={20}
width={20}
/>
</button>
Expand Down
Loading