diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss index ec7051428..5b04888b9 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss @@ -34,7 +34,7 @@ .filterWrap { min-width: 280px; max-width: 360px; - + @include ltemd { max-width: unset; min-width: unset; diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss index fbb797c1b..c4bfce2e9 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss @@ -118,6 +118,38 @@ } } +.testStatusCell { + text-align: center !important; +} + +.testStatusIcon { + align-items: center; + display: inline-flex; + height: 20px; + justify-content: center; + vertical-align: middle; + width: 20px; + + svg { + display: block; + fill: currentcolor; + height: 18px; + width: 18px; + } +} + +.testStatusInProgress { + color: #d97706; +} + +.testStatusSuccess { + color: #137d60; +} + +.testStatusFailed { + color: #ea1900; +} + .level-1 { color: #555 !important; } @@ -144,6 +176,6 @@ } .table { - min-width: 1080px; + min-width: 1180px; } } diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index c264c8dbb..993fca3b8 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -4,6 +4,13 @@ import { render, screen } from '@testing-library/react' import { SubmissionsTable } from './SubmissionsTable' jest.mock('~/libs/ui', () => ({ + IconOutline: { + ClockIcon: (): JSX.Element => , + XCircleIcon: (): JSX.Element => , + }, + IconSolid: { + CheckCircleIcon: (): JSX.Element => , + }, LoadingSpinner: () =>
Loading
, }), { virtual: true, @@ -21,6 +28,28 @@ jest.mock('../../utils', () => ({ getSubmissionInitialScore: (submission: { review?: Array<{ initialScore?: number }> }) => ( submission.review?.[0]?.initialScore ?? 0 ), + getSubmissionTestProgress: ( + submission: { + reviewSummation?: Array<{ + metadata?: { + testProcess?: 'provisional' | 'system' + testProgress?: number + testStatus?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' + } + }> + }, + ) => { + const metadata = submission.reviewSummation?.[0]?.metadata + const progress = metadata?.testProgress + + return { + process: metadata?.testProcess, + progressPercent: typeof progress === 'number' + ? `${Math.round(progress * 100)}%` + : undefined, + status: metadata?.testStatus, + } + }, })) jest.mock('../../assets/icons/IconDownloadArtifacts.svg', () => ({ ReactComponent: () => , @@ -142,4 +171,85 @@ describe('SubmissionsTable', () => { expect(screen.getByRole('button', { name: 'Download submission artifacts' })) .toBeTruthy() }) + + it('renders marathon test progress columns when enabled', () => { + render( + , + ) + + expect(screen.getByText('Current tests process')) + .toBeTruthy() + expect(screen.getByText('Test status')) + .toBeTruthy() + expect(screen.getByText('Test progress')) + .toBeTruthy() + expect(screen.getByText('75%')) + .toBeTruthy() + expect(screen.getByText('100%')) + .toBeTruthy() + expect(screen.getByText('20%')) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: IN PROGRESS' })) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: SUCCESS' })) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: FAILED' })) + .toBeTruthy() + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index 2d89b244f..f5f05757a 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -1,10 +1,15 @@ import { FC, MouseEvent, + ReactElement, } from 'react' import classNames from 'classnames' -import { LoadingSpinner } from '~/libs/ui' +import { + IconOutline, + IconSolid, + LoadingSpinner, +} from '~/libs/ui' import { COMMUNITY_APP_URL, REVIEW_APP_URL } from '../../constants' import { ReactComponent as IconDownloadArtifacts } from '../../assets/icons/IconDownloadArtifacts.svg' @@ -15,6 +20,7 @@ import { getRatingLevel, getSubmissionFinalScore, getSubmissionInitialScore, + getSubmissionTestProgress, } from '../../utils' import styles from './SubmissionsTable.module.scss' @@ -45,6 +51,7 @@ interface SubmissionsTableProps { onSort: (fieldName: SubmissionSortBy) => void sortBy: SubmissionSortBy sortOrder: SortOrder + showMarathonMatchTestProgress?: boolean submissionDownloadLoading?: Record submissions: Submission[] } @@ -70,6 +77,21 @@ const BASE_COLUMNS: ColumnConfig[] = [ label: 'Initial / Final Score', sortable: true, }, +] + +const MARATHON_MATCH_TEST_COLUMNS: ColumnConfig[] = [ + { + label: 'Current tests process', + }, + { + label: 'Test status', + }, + { + label: 'Test progress', + }, +] + +const TRAILING_COLUMNS: ColumnConfig[] = [ { fieldName: 'submissionId', label: 'Submission ID (UUID)', @@ -80,6 +102,25 @@ const BASE_COLUMNS: ColumnConfig[] = [ }, ] +/** + * Builds the table columns for standard and marathon submission rows. + * @param showMarathonMatchTestProgress Whether marathon test-progress metadata should be displayed. + * @returns Column config used by the table header and empty/loading colspans. + * Used by `SubmissionsTable` to insert marathon-only progress columns before actions. + */ +function getColumns(showMarathonMatchTestProgress: boolean): ColumnConfig[] { + return showMarathonMatchTestProgress + ? [ + ...BASE_COLUMNS, + ...MARATHON_MATCH_TEST_COLUMNS, + ...TRAILING_COLUMNS, + ] + : [ + ...BASE_COLUMNS, + ...TRAILING_COLUMNS, + ] +} + function getCreatedAt(submission: Submission): string { return submission.createdAt || submission.created @@ -139,10 +180,59 @@ function getEmailDisplay( : '-' } +/** + * Renders the marathon test status icon for a submission row. + * @param status Normalized test status from review summation metadata. + * @returns Status icon element or `undefined` when no status is available. + * Used by `SubmissionsTable` to keep empty status cells blank. + */ +function renderTestStatusIcon(status: string | undefined): ReactElement | undefined { + if (status === 'IN PROGRESS') { + return ( + + + ) + } + + if (status === 'SUCCESS') { + return ( + + + ) + } + + if (status === 'FAILED') { + return ( + + + ) + } + + return undefined +} + export const SubmissionsTable: FC = ( props: SubmissionsTableProps, ) => { - const columns = BASE_COLUMNS + const columns = getColumns(!!props.showMarathonMatchTestProgress) function handleSortButtonClick(event: MouseEvent): void { const sortBy = event.currentTarget.dataset.fieldName as SubmissionSortBy | undefined @@ -225,6 +315,9 @@ export const SubmissionsTable: FC = ( const submissionDate = formatDateTime(getCreatedAt(submission)) const initialScore = formatScore(getSubmissionInitialScore(submission)) const finalScore = formatScore(getSubmissionFinalScore(submission)) + const testProgress = props.showMarathonMatchTestProgress + ? getSubmissionTestProgress(submission) + : undefined const reviewTab = submission.type === 'CHECKPOINT_SUBMISSION' ? 'checkpoint-submission' : 'submission' @@ -276,6 +369,24 @@ export const SubmissionsTable: FC = ( + {props.showMarathonMatchTestProgress + ? ( + <> + + {testProgress?.process || ''} + + + + {renderTestStatusIcon(testProgress?.status)} + + + + {testProgress?.progressPercent || ''} + + + ) + : undefined} + {submission.id} diff --git a/src/apps/work/src/lib/models/Submission.model.ts b/src/apps/work/src/lib/models/Submission.model.ts index 835a0618e..8efed38cc 100644 --- a/src/apps/work/src/lib/models/Submission.model.ts +++ b/src/apps/work/src/lib/models/Submission.model.ts @@ -1,4 +1,6 @@ export type SubmissionStatus = 'active' | 'completed' | 'deleted' | 'failed' | 'pending' | string +export type MarathonMatchTestProcess = 'provisional' | 'system' | string +export type MarathonMatchTestStatus = 'FAILED' | 'IN PROGRESS' | 'SUCCESS' | string export interface SubmissionReview { createdAt?: string @@ -14,6 +16,29 @@ export interface SubmissionReview { typeId?: string } +export interface ReviewSummationTestProgressDetails { + completedTests?: number + failedTests?: number + message?: string + progress?: number + reviewId?: string + status?: MarathonMatchTestStatus + testProcess?: MarathonMatchTestProcess + totalTests?: number + updatedAt?: string + [key: string]: unknown +} + +export interface ReviewSummationMetadata { + reviewTypeId?: string + testProcess?: MarathonMatchTestProcess + testProgress?: number + testProgressDetails?: ReviewSummationTestProgressDetails + testStatus?: MarathonMatchTestStatus + testType?: MarathonMatchTestProcess + [key: string]: unknown +} + export interface ReviewSummation { aggregateScore?: number createdAt?: string @@ -22,7 +47,9 @@ export interface ReviewSummation { isPassing?: boolean isProvisional?: boolean memberId?: string + metadata?: ReviewSummationMetadata submissionId?: string + updatedAt?: string } export interface Submission { diff --git a/src/apps/work/src/lib/services/submissions.service.ts b/src/apps/work/src/lib/services/submissions.service.ts index a9c93af4c..408be9e9f 100644 --- a/src/apps/work/src/lib/services/submissions.service.ts +++ b/src/apps/work/src/lib/services/submissions.service.ts @@ -6,6 +6,8 @@ import { import { SUBMISSIONS_API_URL } from '../constants' import { ReviewSummation, + ReviewSummationMetadata, + ReviewSummationTestProgressDetails, Submission, SubmissionReview, } from '../models' @@ -148,6 +150,61 @@ function normalizeReview(review: unknown): SubmissionReview | undefined { } } +/** + * Normalizes marathon progress detail metadata from Review API summations. + * @param details Raw `metadata.testProgressDetails` value from a review summation. + * @returns Typed progress detail metadata or `undefined` when absent. + * Used by `normalizeReviewSummationMetadata` before data reaches the submissions table. + */ +function normalizeReviewSummationTestProgressDetails( + details: unknown, +): ReviewSummationTestProgressDetails | undefined { + if (typeof details !== 'object' || !details || Array.isArray(details)) { + return undefined + } + + const typedDetails = details as UnknownRecord + + return { + ...typedDetails, + completedTests: toOptionalNumber(typedDetails.completedTests), + failedTests: toOptionalNumber(typedDetails.failedTests), + message: toOptionalString(typedDetails.message), + progress: toOptionalNumber(typedDetails.progress), + reviewId: toOptionalString(typedDetails.reviewId), + status: toOptionalString(typedDetails.status), + testProcess: toOptionalString(typedDetails.testProcess), + totalTests: toOptionalNumber(typedDetails.totalTests), + updatedAt: toOptionalString(typedDetails.updatedAt), + } +} + +/** + * Normalizes Review API summation metadata while preserving unknown custom fields. + * @param metadata Raw `metadata` object returned on a review summation. + * @returns Typed metadata with marathon test process/status/progress fields normalized. + * Used by `normalizeReviewSummation` so UI code can render marathon progress columns. + */ +function normalizeReviewSummationMetadata(metadata: unknown): ReviewSummationMetadata | undefined { + if (typeof metadata !== 'object' || !metadata || Array.isArray(metadata)) { + return undefined + } + + const typedMetadata = metadata as UnknownRecord + + return { + ...typedMetadata, + reviewTypeId: toOptionalString(typedMetadata.reviewTypeId), + testProcess: toOptionalString(typedMetadata.testProcess), + testProgress: toOptionalNumber(typedMetadata.testProgress), + testProgressDetails: normalizeReviewSummationTestProgressDetails( + typedMetadata.testProgressDetails, + ), + testStatus: toOptionalString(typedMetadata.testStatus), + testType: toOptionalString(typedMetadata.testType), + } +} + function normalizeReviewSummation(reviewSummation: unknown): ReviewSummation | undefined { if (typeof reviewSummation !== 'object' || !reviewSummation) { return undefined @@ -163,7 +220,9 @@ function normalizeReviewSummation(reviewSummation: unknown): ReviewSummation | u isPassing: toOptionalBoolean(typedReviewSummation.isPassing), isProvisional: toOptionalBoolean(typedReviewSummation.isProvisional), memberId: toOptionalString(typedReviewSummation.memberId), + metadata: normalizeReviewSummationMetadata(typedReviewSummation.metadata), submissionId: toOptionalString(typedReviewSummation.submissionId), + updatedAt: toOptionalString(typedReviewSummation.updatedAt), } } diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts index 544ad95ad..11b79d221 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.ts @@ -24,6 +24,23 @@ interface ScoredSubmissionLike { submissions?: SubmissionScore[] } +/** + * Display metadata for marathon test progress columns. + */ +export interface SubmissionTestProgressDisplay { + process?: 'provisional' | 'system' + progressPercent?: string + status?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' +} + +interface SubmissionTestProgressCandidate extends SubmissionTestProgressDisplay { + inProgressPriority: number + processPriority: number + progress?: number + statusPriority: number + updatedAt: number +} + function getPhaseStartDate(phase: ChallengePhase): string { const phaseStartDate = phase.actualStartDate || phase.scheduledStartDate @@ -122,6 +139,197 @@ function getChallengeTypeName(type: string | ChallengeTypeRef | undefined): stri return type.name } +/** + * Normalizes review summation metadata test process aliases for marathon display. + * @param value Metadata process or legacy test type value from Review API. + * @returns `provisional` or `system` when the value identifies a tracked process. + * Used by `getSubmissionTestProgress` to avoid showing example-test metadata. + */ +function normalizeTestProcess(value: unknown): 'provisional' | 'system' | undefined { + const normalized = typeof value === 'string' + ? value.trim() + .toLowerCase() + : '' + + if (normalized === 'system' || normalized === 'final') { + return 'system' + } + + if (normalized === 'provisional') { + return 'provisional' + } + + return undefined +} + +/** + * Normalizes review summation metadata test status for marathon display. + * @param value Metadata status value from Review API. + * @returns Supported UI status or `undefined` when the status is absent/unknown. + * Used by `getSubmissionTestProgress` before choosing the current summation. + */ +function normalizeTestStatus(value: unknown): 'FAILED' | 'IN PROGRESS' | 'SUCCESS' | undefined { + const normalized = typeof value === 'string' + ? value.trim() + .toUpperCase() + : '' + + if (normalized === 'FAILED' || normalized === 'IN PROGRESS' || normalized === 'SUCCESS') { + return normalized + } + + return undefined +} + +/** + * Normalizes review summation metadata progress into the supported 0-to-1 range. + * @param value Metadata progress value from Review API. + * @returns Clamped numeric progress or `undefined` when no finite value exists. + * Used by `getSubmissionTestProgress` to format percent text. + */ +function normalizeTestProgress(value: unknown): number | undefined { + const progress = typeof value === 'string' + ? Number(value) + : value + + if (typeof progress !== 'number' || !Number.isFinite(progress)) { + return undefined + } + + return Math.min(Math.max(progress, 0), 1) +} + +/** + * Resolves the best timestamp available for ordering test progress summations. + * @param entry Review summation containing marathon metadata. + * @returns Epoch milliseconds or 0 when no parseable timestamp exists. + * Used by `getSubmissionTestProgress` to choose the latest non-running process. + */ +function getTestProgressUpdatedAt(entry: ReviewSummation): number { + const updatedAt = entry.metadata?.testProgressDetails?.updatedAt + || entry.updatedAt + || entry.createdAt + || '' + const parsedTimestamp = Date.parse(updatedAt) + + return Number.isFinite(parsedTimestamp) + ? parsedTimestamp + : 0 +} + +/** + * Builds a sortable marathon test-progress candidate from review summation metadata. + * @param entry Review summation returned with optional metadata from Review API. + * @returns Candidate display data or `undefined` when no marathon progress metadata exists. + * Used by `getSubmissionTestProgress` to select the current process for a submission row. + */ +function toSubmissionTestProgressCandidate( + entry: ReviewSummation, +): SubmissionTestProgressCandidate | undefined { + const process = normalizeTestProcess( + entry.metadata?.testProcess + ?? entry.metadata?.testType, + ) + const status = normalizeTestStatus(entry.metadata?.testStatus) + const progress = normalizeTestProgress(entry.metadata?.testProgress) + let statusPriority = 0 + + if (status === 'FAILED') { + statusPriority = 2 + } else if (status === 'SUCCESS') { + statusPriority = 1 + } + + if (!process && !status && progress === undefined) { + return undefined + } + + return { + inProgressPriority: status === 'IN PROGRESS' + ? 1 + : 0, + process, + processPriority: process === 'system' + ? 1 + : 0, + progress, + progressPercent: progress === undefined + ? undefined + : `${Math.round(progress * 100)}%`, + status, + statusPriority, + updatedAt: getTestProgressUpdatedAt(entry), + } +} + +/** + * Returns display-ready marathon match test progress for one submission. + * @param submission Submission-like object containing Review API summations. + * @returns Current process, status, and percent text when present in summation metadata. + * Used by `SubmissionsTable` to render marathon-only test progress columns. + */ +export function getSubmissionTestProgress( + submission: Pick, +): SubmissionTestProgressDisplay { + const candidates = (submission.reviewSummation || []) + .map(entry => toSubmissionTestProgressCandidate(entry)) + .filter((entry): entry is SubmissionTestProgressCandidate => !!entry) + .sort((first, second) => ( + second.inProgressPriority - first.inProgressPriority + || second.updatedAt - first.updatedAt + || second.processPriority - first.processPriority + || second.statusPriority - first.statusPriority + )) + + if (!candidates.length) { + return {} + } + + const current = candidates[0] + + return { + process: current.process, + progressPercent: current.progressPercent, + status: current.status, + } +} + +/** + * Normalizes challenge type labels for equality checks. + * @param value Challenge type name, abbreviation, or tag value. + * @returns Lowercase alphanumeric text with separators removed. + * Used by `isMarathonMatchChallenge` to compare inconsistent API payload shapes. + */ +function normalizeChallengeTypeToken(value: unknown): string { + return typeof value === 'string' + ? value.replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + : '' +} + +/** + * Returns whether the challenge is a Marathon Match. + * @param challenge Challenge payload from the challenge API. + * @returns `true` when the type or tags identify Marathon Match. + * Used by the submissions view to enable marathon-only test progress columns. + */ +export function isMarathonMatchChallenge(challenge: Pick): boolean { + const typeName = getChallengeTypeName(challenge.type) + const typeAbbreviation = typeof challenge.type === 'object' + ? challenge.type?.abbreviation + : undefined + const typeTokens = [ + typeName, + typeAbbreviation, + ...(Array.isArray(challenge.tags) + ? challenge.tags + : []), + ].map(normalizeChallengeTypeToken) + + return typeTokens.includes('marathonmatch') + || typeTokens.includes('mm') +} + export function getStatusText(status?: string, selfService: boolean = false): string { const normalizedStatus = normalizeStatus(status) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx index 8d6476b35..2000c0e57 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx @@ -32,6 +32,7 @@ import { canDownloadSubmissions, getSubmissionFinalScore, getSubmissionInitialScore, + isMarathonMatchChallenge, showErrorToast, } from '../../../../../lib/utils' import { ReactComponent as LockIcon } from '../../../../../lib/assets/icons/lock.svg' @@ -353,6 +354,7 @@ export const SubmissionsSection: FC = ( const workAppContext = useContext(WorkAppContext) const canDownload = canDownloadSubmissions(workAppContext.userRoles) + const isMarathonMatch = isMarathonMatchChallenge(props.challenge) const submissionsResult = useFetchSubmissions( props.challengeId, @@ -648,6 +650,7 @@ export const SubmissionsSection: FC = ( onSort={handleSort} sortBy={sortBy} sortOrder={sortOrder} + showMarathonMatchTestProgress={isMarathonMatch} submissionDownloadLoading={downloadSubmissionResult.isLoading} submissions={paginatedSubmissions} /> @@ -667,6 +670,7 @@ export const SubmissionsSection: FC = ( onSort={handleSort} sortBy={sortBy} sortOrder={sortOrder} + showMarathonMatchTestProgress={isMarathonMatch} submissionDownloadLoading={downloadSubmissionResult.isLoading} submissions={sortedCheckpointSubmissions} />