From d984edd5cfc116a17e8b2d1e0ee4a9a9032d9ab4 Mon Sep 17 00:00:00 2001 From: Kiril Kartunov Date: Thu, 12 Mar 2026 17:38:25 +0200 Subject: [PATCH 01/11] Update Trivy action to version 0.35.0 --- .github/workflows/trivy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa4839..9cbcf5209 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true From c8377c62817ed662ac1cfc1c1b598b0638d9b978 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 31 Mar 2026 12:25:06 +1100 Subject: [PATCH 02/11] PM-4609: include unscored MM submissions in the submissions tab What was broken Marathon Match submissions without review summations were omitted from the submissions tab, so newer uploads could exist in the challenge data but never render in the list. Root cause The client rebuilt Marathon Match submission rows only from review summations. Any raw submission record that had not produced a summation yet was dropped from the derived data. What was changed Updated the Marathon Match submission builder to merge raw challenge submissions with review summations, preserve member metadata, and keep unscored attempts visible in submission history. Wired challenge detail state to pass raw submissions into that builder. Any added/updated tests Added utility coverage for raw Marathon Match submissions without summations and for newer raw submissions that should remain visible alongside scored attempts. --- .../shared/utils/mm-review-summations.test.js | 97 ++++++++ .../containers/challenge-detail/index.jsx | 9 +- src/shared/utils/mm-review-summations.js | 233 +++++++++++++++++- 3 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 __tests__/shared/utils/mm-review-summations.test.js diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js new file mode 100644 index 000000000..e6e9490fe --- /dev/null +++ b/__tests__/shared/utils/mm-review-summations.test.js @@ -0,0 +1,97 @@ +/* eslint-env jest */ +import { buildMmSubmissionData } from '../../../src/shared/utils/mm-review-summations'; + +describe('buildMmSubmissionData', () => { + it('keeps newer raw submissions that do not have review summations yet', () => { + const reviewSummations = [ + { + aggregateScore: 100, + id: 'summation-old', + isProvisional: true, + reviewedDate: '2026-03-30T11:06:00.000Z', + submissionId: 'submission-old', + submitterHandle: 'alpha', + submitterId: '1001', + submitterMaxRating: 1800, + }, + ]; + + const rawSubmissions = [ + { + createdAt: '2026-03-30T11:06:00.000Z', + id: 'submission-old', + memberId: '1001', + registrant: { + memberHandle: 'alpha', + memberId: '1001', + rating: 1800, + }, + status: 'completed', + }, + { + createdAt: '2026-03-30T11:21:00.000Z', + id: 'submission-new', + memberId: '1001', + registrant: { + memberHandle: 'alpha', + memberId: '1001', + rating: 1800, + }, + status: 'queued', + }, + ]; + + const result = buildMmSubmissionData(reviewSummations, rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0].member).toBe('alpha'); + expect(result[0].provisionalRank).toBeNull(); + expect(result[0].submissions).toHaveLength(2); + expect(result[0].submissions[0]).toEqual(expect.objectContaining({ + provisionalScore: null, + status: 'queued', + submissionId: 'submission-new', + })); + expect(result[0].submissions[1]).toEqual(expect.objectContaining({ + provisionalScore: 100, + status: 'completed', + submissionId: 'submission-old', + })); + }); + + it('builds submission rows from raw marathon match submissions when no summations exist', () => { + const rawSubmissions = [ + { + createdAt: '2026-03-30T11:13:00.000Z', + id: 'submission-only', + memberId: '1002', + registrant: { + memberHandle: 'beta', + memberId: '1002', + rating: 1500, + }, + status: 'failed', + }, + ]; + + const result = buildMmSubmissionData([], rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ + member: 'beta', + memberId: '1002', + provisionalRank: null, + finalRank: null, + registrant: expect.objectContaining({ + memberHandle: 'beta', + userId: '1002', + }), + })); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + status: 'failed', + submissionId: 'submission-only', + }), + ]); + }); +}); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 3327d436b..abad65450 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -1150,20 +1150,23 @@ function mapStateToProps(state, props) { const challengeId = String(props.match.params.challengeId); const cl = state.challengeListing; const { lookup: { allCountries, reviewTypes } } = state; + const challenge = state.challenge.details || {}; const reviewSummations = extractArrayFromStateSlice( state.challenge.reviewSummations, challengeId, ); + const rawChallengeSubmissions = Array.isArray(challenge.submissions) + ? challenge.submissions + : (_.get(challenge, 'submissions.data') || []); let mmSubmissions = extractArrayFromStateSlice(state.challenge.mmSubmissions, challengeId); - if (!mmSubmissions.length && reviewSummations.length) { - mmSubmissions = buildMmSubmissionData(reviewSummations); + if (reviewSummations.length || rawChallengeSubmissions.length) { + mmSubmissions = buildMmSubmissionData(reviewSummations, rawChallengeSubmissions); } const { auth } = state; let statisticsData = extractArrayFromStateSlice(state.challenge.statisticsData, challengeId); if (!hasRenderableStatisticsData(statisticsData) && reviewSummations.length) { statisticsData = buildStatisticsData(reviewSummations); } - const challenge = state.challenge.details || {}; const reviewSummationLookup = reviewSummations.length ? buildReviewSummationLookup(reviewSummations) : null; diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index 89071b46b..46000abe9 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -77,6 +77,81 @@ function getSummationRating(summation) { return _.isNil(rating) ? null : rating; } +function getSubmissionHandle(submission) { + const handle = _.get(submission, 'registrant.memberHandle') + || _.get(submission, 'memberHandle') + || _.get(submission, 'createdBy'); + + if (!handle || !_.isString(handle) || !handle.trim()) { + return 'unknown'; + } + + return handle; +} + +function getSubmissionMemberId(submission) { + const memberId = _.get(submission, 'memberId', _.get(submission, 'registrant.memberId')); + return _.isNil(memberId) ? null : _.toString(memberId); +} + +function getSubmissionRating(submission) { + const rating = _.get(submission, 'rating', _.get(submission, 'registrant.rating')); + return _.isNil(rating) ? null : rating; +} + +function getSubmissionTimestamp(submission) { + const candidates = [ + _.get(submission, 'submissionTime'), + _.get(submission, 'created'), + _.get(submission, 'createdAt'), + _.get(submission, 'submittedDate'), + _.get(submission, 'reviewedDate'), + _.get(submission, 'updated'), + _.get(submission, 'updatedAt'), + ]; + return _.find(candidates, value => !!value) || null; +} + +function getSubmissionIdentifier(submission, index, handle) { + const rawSubmissionId = _.get( + submission, + 'submissionId', + _.get(submission, 'id'), + ); + + return rawSubmissionId + ? _.toString(rawSubmissionId) + : `unknown-${handle}-${index}`; +} + +function dedupeReviewSummations(reviewSummations = []) { + const deduped = []; + const seen = new Set(); + + reviewSummations.forEach((summation, index) => { + if (!summation) { + return; + } + + const dedupeKey = _.toString(_.get(summation, 'id', '')).trim() + || `${_.toString(_.get(summation, 'submissionId', '')).trim()}::${_.toString(_.get(summation, 'legacySubmissionId', '')).trim()}::${_.toString(_.get(summation, 'submitterId', '')).trim()}::${_.toString(_.get(summation, 'aggregateScore', '')).trim()}::${_.toString(_.get(summation, 'reviewedDate', '')).trim()}::${index}`; + + if (seen.has(dedupeKey)) { + return; + } + + seen.add(dedupeKey); + deduped.push(summation); + }); + + return deduped; +} + +function normalizeSubmissionStatus(status) { + const normalizedStatus = _.toLower(_.toString(status || '').trim()); + return normalizedStatus || 'completed'; +} + function ensureSubmissionEntry( existingEntry, { submissionId, timestamp, timestampValue }, @@ -241,6 +316,77 @@ function updateSubmissionEntry( }; } +function updateSubmissionEntryFromSubmission( + existingEntry, + { + submissionId, + timestamp, + timestampValue, + provisionalScore, + finalScore, + status, + isLatest, + reviewSummations, + }, +) { + const baseEntry = ensureSubmissionEntry(existingEntry, { + submissionId, + timestamp, + timestampValue, + }); + + let { submissionTime, latestTimestamp } = baseEntry; + let submissionIsLatest = baseEntry.isLatest; + + if (timestampValue > latestTimestamp) { + latestTimestamp = timestampValue; + submissionTime = timestamp || submissionTime; + } else if (!submissionTime && timestamp) { + submissionTime = timestamp; + } + + if (!_.isNil(isLatest)) { + submissionIsLatest = Boolean(isLatest); + } + + const provisionalResult = _.isNil(provisionalScore) + ? { meta: baseEntry.provisionalMeta, value: baseEntry.provisionalScore } + : mergeScoreData( + baseEntry.provisionalMeta, + baseEntry.provisionalScore, + provisionalScore, + timestampValue, + ); + + const finalResult = _.isNil(finalScore) + ? { meta: baseEntry.finalMeta, value: baseEntry.finalScore } + : mergeScoreData( + baseEntry.finalMeta, + baseEntry.finalScore, + finalScore, + timestampValue, + ); + + const mergedReviewSummations = dedupeReviewSummations([ + ...baseEntry.reviewSummations, + ...(Array.isArray(reviewSummations) ? reviewSummations : []), + ]); + + return { + ...baseEntry, + submissionTime, + latestTimestamp, + isLatest: submissionIsLatest, + provisionalMeta: provisionalResult.meta, + provisionalScore: provisionalResult.value, + finalMeta: finalResult.meta, + finalScore: finalResult.value, + status: normalizeSubmissionStatus(status || baseEntry.status), + reviewSummations: mergedReviewSummations, + reviewSummation: [...mergedReviewSummations], + }; +} + function assignRanks(members, scoreKey, rankKey, options = {}) { const { tieBreaker } = options; @@ -352,14 +498,32 @@ function updateStatisticsSubmission( }; } -export function buildMmSubmissionData(reviewSummations = []) { - if (!Array.isArray(reviewSummations) || !reviewSummations.length) { +/** + * Builds Marathon Match submission rows from review summations and raw challenge + * submission records. + * + * Raw submissions are merged so the UI can still render attempts that have not + * produced review summations yet. + * + * @param {Array} reviewSummations review summations returned by review API + * @param {Array} rawSubmissions raw challenge submissions returned by challenge details + * @returns {Array} member-grouped Marathon Match submission rows + */ +export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = []) { + const normalizedReviewSummations = Array.isArray(reviewSummations) + ? reviewSummations + : []; + const normalizedRawSubmissions = Array.isArray(rawSubmissions) + ? rawSubmissions + : []; + + if (!normalizedReviewSummations.length && !normalizedRawSubmissions.length) { return []; } const membersByHandle = new Map(); - reviewSummations.forEach((summation, index) => { + normalizedReviewSummations.forEach((summation, index) => { if (!summation) { return; } @@ -424,9 +588,72 @@ export function buildMmSubmissionData(reviewSummations = []) { memberEntry.submissionsMap.set(submissionId, updatedEntry); }); + normalizedRawSubmissions.forEach((submission, index) => { + if (!submission) { + return; + } + + const handle = getSubmissionHandle(submission); + if (!membersByHandle.has(handle)) { + membersByHandle.set(handle, { + handle, + memberId: null, + rating: null, + submissionsMap: new Map(), + }); + } + + const memberEntry = membersByHandle.get(handle); + const memberId = getSubmissionMemberId(submission); + if (!memberEntry.memberId && memberId) { + memberEntry.memberId = memberId; + } + + const rating = getSubmissionRating(submission); + if (_.isNil(memberEntry.rating) && !_.isNil(rating)) { + memberEntry.rating = rating; + } + + const submissionId = getSubmissionIdentifier(submission, index, handle); + const timestamp = getSubmissionTimestamp(submission); + const timestampValue = toTimestampValue(timestamp); + const provisionalScore = normalizeScoreValue( + _.get(submission, 'provisionalScore', _.get(submission, 'initialScore')), + ); + const finalScore = normalizeScoreValue(_.get(submission, 'finalScore')); + const isLatest = _.isNil(submission.isLatest) + ? null + : Boolean(submission.isLatest); + const reviewSummation = dedupeReviewSummations([ + ...(Array.isArray(_.get(submission, 'reviewSummations')) + ? _.get(submission, 'reviewSummations') + : []), + ...(Array.isArray(_.get(submission, 'reviewSummation')) + ? _.get(submission, 'reviewSummation') + : []), + ]); + + const updatedEntry = updateSubmissionEntryFromSubmission( + memberEntry.submissionsMap.get(submissionId), + { + submissionId, + timestamp, + timestampValue, + provisionalScore, + finalScore, + status: _.get(submission, 'status'), + isLatest, + reviewSummations: reviewSummation, + }, + ); + + memberEntry.submissionsMap.set(submissionId, updatedEntry); + }); + const members = Array.from(membersByHandle.values()).map((memberEntry) => { const submissions = Array.from(memberEntry.submissionsMap.values()) .map(submission => ({ + id: submission.submissionId, submissionId: submission.submissionId, submissionTime: submission.submissionTime, isLatest: submission.isLatest, From fe4d298d7d448e7a2951b18938b6f7eca89d4d56 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 07:50:07 +1100 Subject: [PATCH 03/11] Ignore isWiproAllowed flag on Topgear Tasks --- .../containers/challenge-detail/index.jsx | 27 ++++++++++++++++--- .../containers/challenge-detail/index.jsx | 10 +++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index cff13840e..19de44a21 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -2,19 +2,38 @@ import { getDisplayWinners, isWiproRegistrationBlocked } from 'containers/challe describe('Challenge detail Wipro registration guard', () => { test('blocks Wipro members when challenge disallows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@wipro.com', false)).toBe(true); + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(true); }); test('does not block Wipro members when challenge allows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@wipro.com', true)).toBe(false); + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: true, + type: 'Challenge', + })).toBe(false); }); test('does not block non-Wipro members when challenge disallows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@example.com', false)).toBe(false); + expect(isWiproRegistrationBlocked('member@example.com', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(false); }); test('matches Wipro domain case-insensitively and ignores surrounding spaces', () => { - expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', false)).toBe(true); + expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(true); + }); + + test('does not block Wipro members for Topgear Task even when the flag is false', () => { + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: false, + type: 'Topgear Task', + })).toBe(false); }); }); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index abad65450..6e326949b 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -105,10 +105,12 @@ const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, pleas /** * Checks whether challenge registration should be blocked for Wipro members. * @param {String} email User email. - * @param {Boolean} wiproAllowed Challenge-level flag. + * @param {Object} challenge Challenge details used to determine registration policy. * @return {Boolean} */ -export function isWiproRegistrationBlocked(email, wiproAllowed) { +export function isWiproRegistrationBlocked(email, challenge = {}) { + if (getTypeName(challenge) === 'Topgear Task') return false; + const wiproAllowed = _.get(challenge, 'wiproAllowed'); if (wiproAllowed !== false) return false; return /@wipro\.com$/i.test(_.trim(email || '')); } @@ -412,9 +414,7 @@ class ChallengeDetailPageContainer extends React.Component { communityId, } = this.props; const userEmail = _.get(auth, 'user.email'); - const wiproAllowed = _.get(challenge, 'wiproAllowed'); - - if (isWiproRegistrationBlocked(userEmail, wiproAllowed)) { + if (isWiproRegistrationBlocked(userEmail, challenge)) { fireErrorMessage( WIPRO_REGISTRATION_BLOCKED_MESSAGE, WIPRO_REGISTRATION_SUPPORT_MESSAGE, From bebcd7d6d3567960f13b1718b6491fb166b23b20 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 14:18:23 +1100 Subject: [PATCH 04/11] PM-4648 fixes --- .../challenge-detail/Header/index.jsx | 101 ++++++++++++++++++ .../challenge-detail/Header/index.jsx | 95 ++++++++-------- 2 files changed, 151 insertions(+), 45 deletions(-) create mode 100644 __tests__/shared/components/challenge-detail/Header/index.jsx diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx new file mode 100644 index 000000000..4cd381bc0 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; + +import Header from 'components/challenge-detail/Header'; + +function collectText(node) { + if (typeof node === 'string') { + return [node]; + } + + if (!React.isValidElement(node)) { + return []; + } + + return React.Children.toArray(node.props.children) + .reduce((acc, child) => acc.concat(collectText(child)), []); +} + +function renderHeader(challengeOverrides = {}) { + const renderer = new Renderer(); + renderer.render( +
, + ); + + return renderer.getRenderOutput(); +} + +describe('Challenge detail header actions', () => { + test('hides registration and submission actions for task challenges', () => { + const output = renderHeader({ + task: { + isTask: true, + }, + type: 'Task', + }); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); + + test('shows registration and submission actions for non-task challenges', () => { + const output = renderHeader(); + + expect(collectText(output)).toContain('Register'); + expect(collectText(output)).toContain('Submit a solution'); + }); +}); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 4779b90c4..e8e8b9bf9 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -148,6 +148,9 @@ export default function ChallengeHeader(props) { const trackName = getTrackName(track); const typeName = getTypeName(type); + const isTaskChallenge = typeName === 'Task' + || _.get(challenge, 'task.isTask') === true + || _.get(challenge, 'legacy.pureV5Task') === true; const trackLower = trackName ? trackName.replace(' ', '-').toLowerCase() : 'design'; const eventNames = (events || []).map((event => (event.eventName || '').toUpperCase())); @@ -436,55 +439,57 @@ export default function ChallengeHeader(props) {
{!isTopCrowdChallenge ? ( -
- {hasRegistered ? ( - - Unregister - - ) : ( + !isTaskChallenge && ( +
+ {hasRegistered ? ( + + Unregister + + ) : ( + + Register + + )} - Register + + Submit a solution - )} - - - Submit a solution - - { - trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering - && hasSubmissions && ( - - View Submissions - - ) - } -
+ { + trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering + && hasSubmissions && ( + + View Submissions + + ) + } +
+ ) ) : ( Date: Wed, 1 Apr 2026 15:15:02 +1100 Subject: [PATCH 05/11] Fix failing test --- .../challenge-detail/Header/index.jsx | 98 +++++++++++++ .../challenge-detail/Header/index.jsx | 138 ++++++++++-------- 2 files changed, 174 insertions(+), 62 deletions(-) create mode 100644 __tests__/shared/components/challenge-detail/Header/index.jsx diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx new file mode 100644 index 000000000..faab3b79d --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; + +import Header from 'components/challenge-detail/Header'; + +const defaultProps = { + challenge: { + id: '300001', + drPoints: null, + events: [], + funChallenge: false, + metadata: [], + name: 'Challenge title', + numOfCheckpointSubmissions: 0, + numOfRegistrants: 0, + numOfSubmissions: 0, + phases: [ + { + name: 'Registration', + isOpen: true, + scheduledEndDate: '2030-01-02T00:00:00.000Z', + }, + ], + pointPrizes: [], + prizeSets: [ + { + type: 'placement', + prizes: [{ type: 'USD', value: 1000 }], + }, + ], + reliabilityBonus: 0, + skills: [], + status: 'ACTIVE', + tags: [], + track: 'Development', + type: 'Challenge', + }, + challengeTypesMap: {}, + challengesUrl: '/challenges', + checkpoints: {}, + hasFirstPlacement: false, + hasRecommendedChallenges: false, + hasRegistered: false, + hasThriveArticles: false, + isLoggedIn: true, + mySubmissions: [], + numWinners: 0, + onSelectorClicked: () => {}, + onSort: () => {}, + onToggleDeadlines: () => {}, + openForRegistrationChallenges: {}, + registerForChallenge: () => {}, + registering: false, + selectedView: 'details', + setChallengeListingFilter: () => {}, + showDeadlineDetail: false, + submissionEnded: false, + unregisterFromChallenge: () => {}, + unregistering: false, + viewAsTable: false, +}; + +/** + * Collects text nodes from a shallow-rendered React tree. + * @param {*} node React node to inspect. + * @returns {string[]} Flattened text content. + */ +function collectText(node) { + if (typeof node === 'string') return [node]; + if (!node || !node.props) return []; + + return React.Children.toArray(node.props.children).reduce( + (result, child) => result.concat(collectText(child)), + [], + ); +} + +describe('Challenge detail header actions', () => { + test('hides registration and submission actions for task challenges', () => { + const renderer = new Renderer(); + + renderer.render(( +
+ )); + + const output = renderer.getRenderOutput(); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); +}); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 4779b90c4..a517961b1 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -148,6 +148,7 @@ export default function ChallengeHeader(props) { const trackName = getTrackName(track); const typeName = getTypeName(type); + const isTaskChallenge = typeName === 'Task'; const trackLower = trackName ? trackName.replace(' ', '-').toLowerCase() : 'design'; const eventNames = (events || []).map((event => (event.eventName || '').toUpperCase())); @@ -184,9 +185,14 @@ export default function ChallengeHeader(props) { const deadlineEnd = moment(nextPhase && phaseEndDate(nextPhase)); const currentTime = moment(); - const timeDiff = getTimeLeft(currentPhases || allOpenPhases[0], 'to go', true); + const activePhase = currentPhases || allOpenPhases[0]; + const shouldShowCurrentPhase = !isTaskChallenge + || (activePhase && !isRegistrationPhase(activePhase)); + const timeDiff = shouldShowCurrentPhase + ? getTimeLeft(activePhase, 'to go', true) + : null; - if (!timeDiff.late) { + if (timeDiff && !timeDiff.late) { timeDiff.text = timeDiff.text.replace('to go', ''); } @@ -316,6 +322,72 @@ export default function ChallengeHeader(props) { || !isActivedChallenge; const unregisterButtonDisabled = unregistering || registrationEnded || hasSubmissions || isLegacyMM; + let challengeActions = null; + + if (!isTopCrowdChallenge && !isTaskChallenge) { + challengeActions = ( +
+ {hasRegistered ? ( + + Unregister + + ) : ( + + Register + + )} + + + Submit a solution + + { + trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering + && hasSubmissions && ( + + View Submissions + + ) + } +
+ ); + } else if (isTopCrowdChallenge) { + challengeActions = ( + + View details on Topcoder platform + + + ); + } return (
@@ -435,66 +507,7 @@ export default function ChallengeHeader(props) { }
- {!isTopCrowdChallenge ? ( -
- {hasRegistered ? ( - - Unregister - - ) : ( - - Register - - )} - - - Submit a solution - - { - trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering - && hasSubmissions && ( - - View Submissions - - ) - } -
- ) : ( - - View details on Topcoder platform - - - )} + {challengeActions}
@@ -503,6 +516,7 @@ export default function ChallengeHeader(props) { {nextDeadlineMsg} { (status || '').toLowerCase() === 'active' + && timeDiff && (
{currentPhases && `${currentPhases.name} Ends In: `} From 0b57f80af80a314312723ec905a805f8f6c5e28e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 1 Apr 2026 22:52:36 +1100 Subject: [PATCH 06/11] PM-4662: fix MM provisional score display What was broken - Marathon Match provisional scores were showing under Final Score in My Submissions before review was complete. - The public Submissions tab could show stale provisional scores of 0 even when the scored attempt was 100. Root cause - The MM normalization path preferred raw provisionalScore values over initialScore, even when provisionalScore was stale. - My Submissions rendered finalScore directly instead of treating the review phase as the point when final scores become visible. What was changed - Prefer initialScore when normalizing MM provisional scores from raw submissions and normalized attempts. - Added a display helper in My Submissions so provisional scores come from the initial score and final scores stay hidden until review completes. - Tightened the existing header test fixture so the full Jest suite no longer confuses the registration deadline text with the Register action. Any added/updated tests - Added a regression test for stale MM provisional scores in mm-review-summations. - Added My Submissions display tests covering pre-review and post-review score rendering. - Updated the challenge-detail header action test fixture used by the full Jest suite. --- .../challenge-detail/Header/index.jsx | 14 ++++++ .../MySubmissions/SubmissionsList/index.jsx | 47 +++++++++++++++++++ .../shared/utils/mm-review-summations.test.js | 31 ++++++++++++ .../MySubmissions/SubmissionsList/index.jsx | 32 ++++++++++++- .../containers/challenge-detail/index.jsx | 6 +++ src/shared/utils/mm-review-summations.js | 2 +- 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 __tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 4cd381bc0..2e5b967b4 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -81,6 +81,20 @@ function renderHeader(challengeOverrides = {}) { describe('Challenge detail header actions', () => { test('hides registration and submission actions for task challenges', () => { const output = renderHeader({ + phases: [ + { + isOpen: false, + name: 'Registration', + scheduledEndDate: '2030-01-02T00:00:00.000Z', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + { + isOpen: true, + name: 'Submission', + scheduledEndDate: '2030-01-03T00:00:00.000Z', + scheduledStartDate: '2030-01-02T00:00:00.000Z', + }, + ], task: { isTask: true, }, diff --git a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx new file mode 100644 index 000000000..d60f71461 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -0,0 +1,47 @@ +import { getDisplayedScores } from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; + +describe('getDisplayedScores', () => { + test('uses the initial score as the provisional score before review completes', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 100, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: null, + provisionalScore: 100, + }); + }); + + test('shows final scores once the review phase is complete', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 95, + }); + }); +}); diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js index e6e9490fe..a6d721896 100644 --- a/__tests__/shared/utils/mm-review-summations.test.js +++ b/__tests__/shared/utils/mm-review-summations.test.js @@ -94,4 +94,35 @@ describe('buildMmSubmissionData', () => { }), ]); }); + + it('prefers initial scores over stale provisional scores from raw submissions', () => { + const rawSubmissions = [ + { + createdAt: '2026-04-01T00:01:03.000Z', + finalScore: 100, + id: 'submission-stale-provisional', + initialScore: 100, + memberId: '1003', + provisionalScore: 0, + registrant: { + memberHandle: 'gamma', + memberId: '1003', + rating: 1700, + }, + status: 'queued', + }, + ]; + + const result = buildMmSubmissionData([], rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + finalScore: 100, + provisionalScore: 100, + status: 'queued', + submissionId: 'submission-stale-provisional', + }), + ]); + }); }); diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index a17e8b913..127c7a384 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -74,6 +74,36 @@ const getSubmissionCreatedTime = (submission) => { ); }; +/** + * Returns the scores that should be displayed for a marathon match submission row. + * Initial score is the authoritative provisional score for MM submissions, while + * final scores should remain hidden until the review phase has completed. + * + * @param {Object} submission submission attempt shown in My Submissions. + * @param {Object} challenge challenge that owns the submission. + * @returns {{ finalScore: number|null, provisionalScore: number|null }} display-ready scores. + */ +export function getDisplayedScores(submission = {}, challenge = {}) { + const toNumericScore = (value) => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + + const isReviewPhaseComplete = _.some( + challenge.phases || [], + phase => phase.name === 'Review' && !phase.isOpen && moment(phase.scheduledStartDate).isBefore(), + ); + + const initialScore = toNumericScore(_.get(submission, 'initialScore')); + const provisionalScore = toNumericScore(_.get(submission, 'provisionalScore')); + const finalScore = toNumericScore(_.get(submission, 'finalScore')); + + return { + finalScore: isReviewPhaseComplete ? finalScore : null, + provisionalScore: !_.isNil(initialScore) ? initialScore : provisionalScore, + }; +} + class SubmissionsListView extends React.Component { constructor(props) { super(props); @@ -435,7 +465,7 @@ class SubmissionsListView extends React.Component {
{ sortedSubmissions.map((mySubmission) => { - let { finalScore, provisionalScore } = mySubmission; + let { finalScore, provisionalScore } = getDisplayedScores(mySubmission, challenge); if (_.isNumber(finalScore)) { if (finalScore > 0) { finalScore = finalScore.toFixed(2); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 6e326949b..9bd40228c 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -1307,6 +1307,12 @@ function mapStateToProps(state, props) { return toNumericScore(match.aggregateScore); }; + const initialScore = toNumericScore(normalizedAttempt.initialScore); + if (!_.isNil(initialScore)) { + normalizedAttempt.initialScore = initialScore; + normalizedAttempt.provisionalScore = initialScore; + } + const hasProvisionalScore = !_.isNil(normalizedAttempt.provisionalScore); const hasFinalScore = !_.isNil(normalizedAttempt.finalScore); diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index 46000abe9..27a468220 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -618,7 +618,7 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] const timestamp = getSubmissionTimestamp(submission); const timestampValue = toTimestampValue(timestamp); const provisionalScore = normalizeScoreValue( - _.get(submission, 'provisionalScore', _.get(submission, 'initialScore')), + _.get(submission, 'initialScore', _.get(submission, 'provisionalScore')), ); const finalScore = normalizeScoreValue(_.get(submission, 'finalScore')); const isLatest = _.isNil(submission.isLatest) From e0dae54413de56f72aebed67dece3ca5b5bd935f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 14:48:22 +1100 Subject: [PATCH 07/11] PM-4648: hide task actions in challenge header What was broken Task challenge detail pages could still render Register, Unregister, and Submit a solution actions for users who should not see them. Root cause (if identifiable) The challenge detail header only treated challenges with a Task type name as tasks, while tasks created from the newer work app are identified through task metadata on the payload instead. What was changed Updated the challenge detail header to reuse task detection that also respects task metadata on the challenge payload, so task pages no longer render the register, unregister, or submit actions. Resolved the conflicted header/test blocks so the header keeps the current action rendering path and the task guard applies consistently. Any added/updated tests Updated the challenge detail header tests to cover both classic task challenges and work-app task payloads, and to confirm non-task challenges still show the actions. --- .../challenge-detail/Header/index.jsx | 102 ++---------------- .../challenge-detail/Header/index.jsx | 69 ------------ 2 files changed, 11 insertions(+), 160 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 367d3d536..18f85e88b 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -3,78 +3,6 @@ import Renderer from 'react-test-renderer/shallow'; import Header from 'components/challenge-detail/Header'; -<<<<<<< HEAD -const defaultProps = { - challenge: { - id: '300001', - drPoints: null, - events: [], - funChallenge: false, - metadata: [], - name: 'Challenge title', - numOfCheckpointSubmissions: 0, - numOfRegistrants: 0, - numOfSubmissions: 0, - phases: [ - { - name: 'Registration', - isOpen: true, - scheduledEndDate: '2030-01-02T00:00:00.000Z', - }, - ], - pointPrizes: [], - prizeSets: [ - { - type: 'placement', - prizes: [{ type: 'USD', value: 1000 }], - }, - ], - reliabilityBonus: 0, - skills: [], - status: 'ACTIVE', - tags: [], - track: 'Development', - type: 'Challenge', - }, - challengeTypesMap: {}, - challengesUrl: '/challenges', - checkpoints: {}, - hasFirstPlacement: false, - hasRecommendedChallenges: false, - hasRegistered: false, - hasThriveArticles: false, - isLoggedIn: true, - mySubmissions: [], - numWinners: 0, - onSelectorClicked: () => {}, - onSort: () => {}, - onToggleDeadlines: () => {}, - openForRegistrationChallenges: {}, - registerForChallenge: () => {}, - registering: false, - selectedView: 'details', - setChallengeListingFilter: () => {}, - showDeadlineDetail: false, - submissionEnded: false, - unregisterFromChallenge: () => {}, - unregistering: false, - viewAsTable: false, -}; - -/** - * Collects text nodes from a shallow-rendered React tree. - * @param {*} node React node to inspect. - * @returns {string[]} Flattened text content. - */ -function collectText(node) { - if (typeof node === 'string') return [node]; - if (!node || !node.props) return []; - - return React.Children.toArray(node.props.children).reduce( - (result, child) => result.concat(collectText(child)), - [], - ); -======= function collectText(node) { if (typeof node === 'string') { return [node]; @@ -148,40 +76,33 @@ function renderHeader(challengeOverrides = {}) { ); return renderer.getRenderOutput(); ->>>>>>> bebcd7d6d3567960f13b1718b6491fb166b23b20 } describe('Challenge detail header actions', () => { test('hides registration and submission actions for task challenges', () => { -<<<<<<< HEAD - const renderer = new Renderer(); + const output = renderHeader({ + task: { + isTask: true, + }, + type: 'Task', + }); - renderer.render(( -
- )); + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); - const output = renderer.getRenderOutput(); -======= + test('hides registration and submission actions for task payloads from work app', () => { const output = renderHeader({ task: { isTask: true, }, - type: 'Task', }); ->>>>>>> bebcd7d6d3567960f13b1718b6491fb166b23b20 expect(collectText(output)).not.toContain('Register'); expect(collectText(output)).not.toContain('Unregister'); expect(collectText(output)).not.toContain('Submit a solution'); }); -<<<<<<< HEAD -======= test('shows registration and submission actions for non-task challenges', () => { const output = renderHeader(); @@ -189,5 +110,4 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).toContain('Register'); expect(collectText(output)).toContain('Submit a solution'); }); ->>>>>>> bebcd7d6d3567960f13b1718b6491fb166b23b20 }); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index e8b7ac98f..77c945f40 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -148,13 +148,9 @@ export default function ChallengeHeader(props) { const trackName = getTrackName(track); const typeName = getTypeName(type); -<<<<<<< HEAD - const isTaskChallenge = typeName === 'Task'; -======= const isTaskChallenge = typeName === 'Task' || _.get(challenge, 'task.isTask') === true || _.get(challenge, 'legacy.pureV5Task') === true; ->>>>>>> bebcd7d6d3567960f13b1718b6491fb166b23b20 const trackLower = trackName ? trackName.replace(' ', '-').toLowerCase() : 'design'; const eventNames = (events || []).map((event => (event.eventName || '').toUpperCase())); @@ -513,72 +509,7 @@ export default function ChallengeHeader(props) { }
-<<<<<<< HEAD {challengeActions} -======= - {!isTopCrowdChallenge ? ( - !isTaskChallenge && ( -
- {hasRegistered ? ( - - Unregister - - ) : ( - - Register - - )} - - - Submit a solution - - { - trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering - && hasSubmissions && ( - - View Submissions - - ) - } -
- ) - ) : ( - - View details on Topcoder platform - - - )} ->>>>>>> bebcd7d6d3567960f13b1718b6491fb166b23b20
From 50c1a8f9d27c6846ec63c66272f0bf3592595eb9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 16:35:37 +1100 Subject: [PATCH 08/11] PM-4608: show MM final scores during active review What was broken Marathon Match challenge detail pages kept rendering final scores and final ranks as N/A until the Review phase closed, even when review summations had already produced final results during an active review/system-test phase. My Submissions had the same problem for final scores. Root cause The UI gated all final Marathon Match results on review-phase completion instead of on actual result availability. When system tests were configured to run in Review, final scores could already exist in the submission payload before the phase closed. What was changed Added a small challenge-detail utility that detects when final Marathon Match results are already present in the loaded submissions. Updated Marathon Match submission rows, history rows, and the submission details modal to show final ranks and scores as soon as final results exist, and to default MM sorting to final rank when those results are visible. Updated My Submissions score display to surface final scores when they already exist, while preserving the existing provisional-score behavior when no final result is available. Any added/updated tests Added utility coverage for review-phase completion and early final-result visibility. Added My Submissions score-display tests for active review and completed review cases. Included the existing MM review summation test in the focused Jest run to confirm the upstream data shaping still passes. --- .../challenge-detail/mm-final-results.test.js | 59 ++++++++++++++++ .../my-submission-scores.test.js | 70 +++++++++++++++++++ .../MySubmissions/SubmissionsList/index.jsx | 31 +++++++- .../SubmissionInformationModal/index.jsx | 6 +- .../SubmissionHistoryRow/index.jsx | 8 +-- .../Submissions/SubmissionRow/index.jsx | 12 ++-- .../challenge-detail/Submissions/index.jsx | 40 +++++------ .../challenge-detail/mm-final-results.js | 64 +++++++++++++++++ 8 files changed, 253 insertions(+), 37 deletions(-) create mode 100644 __tests__/shared/utils/challenge-detail/mm-final-results.test.js create mode 100644 __tests__/shared/utils/challenge-detail/my-submission-scores.test.js create mode 100644 src/shared/utils/challenge-detail/mm-final-results.js diff --git a/__tests__/shared/utils/challenge-detail/mm-final-results.test.js b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js new file mode 100644 index 000000000..b8e83b757 --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js @@ -0,0 +1,59 @@ +/* eslint-env jest */ +import { + hasVisibleMmFinalResults, + isReviewPhaseComplete, + shouldShowFinalMmResults, +} from '../../../../src/shared/utils/challenge-detail/mm-final-results'; + +describe('mm-final-results utilities', () => { + const activeReviewChallenge = { + phases: [ + { + isOpen: true, + name: 'Review', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }; + + it('detects when the review phase has completed', () => { + expect(isReviewPhaseComplete({ + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + })).toBe(true); + }); + + it('keeps final Marathon Match results hidden while review is active and no final score exists', () => { + expect(shouldShowFinalMmResults(activeReviewChallenge, [ + { + finalRank: null, + submissions: [ + { + finalScore: null, + }, + ], + }, + ])).toBe(false); + }); + + it('shows final Marathon Match results as soon as a final score is available', () => { + const mmSubmissions = [ + { + finalRank: 1, + submissions: [ + { + finalScore: 100, + }, + ], + }, + ]; + + expect(hasVisibleMmFinalResults(mmSubmissions)).toBe(true); + expect(shouldShowFinalMmResults(activeReviewChallenge, mmSubmissions)).toBe(true); + }); +}); diff --git a/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js new file mode 100644 index 000000000..512e1b91b --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js @@ -0,0 +1,70 @@ +/* eslint-env jest */ +import { getDisplayedScores } from '../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; + +describe('getDisplayedScores', () => { + it('shows final scores when a system review has already produced one', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 100, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 100, + }); + }); + + it('hides final scores while review is active and no final result exists yet', () => { + expect(getDisplayedScores( + { + finalScore: null, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: null, + provisionalScore: 95, + }); + }); + + it('shows final scores once the review phase is complete', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 95, + }); + }); +}); diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index a17e8b913..4f9a023a6 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -9,6 +9,7 @@ import moment from 'moment'; import { PrimaryButton, Modal } from 'topcoder-react-ui-kit'; import PT from 'prop-types'; import { services } from 'topcoder-react-lib'; +import { isReviewPhaseComplete } from 'utils/challenge-detail/mm-final-results'; import sortList from 'utils/challenge-detail/sort'; import { getSubmissionStatus } from 'utils/challenge-detail/submission-status'; @@ -74,6 +75,34 @@ const getSubmissionCreatedTime = (submission) => { ); }; +/** + * Returns the scores that should be displayed for a Marathon Match submission. + * + * @param {Object} submission submission attempt shown in My Submissions. + * @param {Object} challenge challenge that owns the submission. + * @returns {{ finalScore: number|null, provisionalScore: number|null }} display-ready scores. + */ +export function getDisplayedScores(submission = {}, challenge = {}) { + const toNumericScore = (value) => { + if (_.isNil(value) || value === '' || value === '-') { + return null; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + + const initialScore = toNumericScore(_.get(submission, 'initialScore')); + const provisionalScore = toNumericScore(_.get(submission, 'provisionalScore')); + const finalScore = toNumericScore(_.get(submission, 'finalScore')); + const showFinalScore = isReviewPhaseComplete(challenge) || !_.isNil(finalScore); + + return { + finalScore: showFinalScore ? finalScore : null, + provisionalScore: !_.isNil(initialScore) ? initialScore : provisionalScore, + }; +} + class SubmissionsListView extends React.Component { constructor(props) { super(props); @@ -435,7 +464,7 @@ class SubmissionsListView extends React.Component {
{ sortedSubmissions.map((mySubmission) => { - let { finalScore, provisionalScore } = mySubmission; + let { finalScore, provisionalScore } = getDisplayedScores(mySubmission, challenge); if (_.isNumber(finalScore)) { if (finalScore > 0) { finalScore = finalScore.toFixed(2); diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx index 328dcf0cf..ea235fe43 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx @@ -61,7 +61,7 @@ class SubmissionInformationModal extends React.Component { render() { const { toggleTestcase, onClose, isLoadingSubmissionInformation, - submissionInformation, isReviewPhaseComplete, + submissionInformation, showFinalResults, } = this.props; const submissionBasicInfo = isLoadingSubmissionInformation ? null : this.getSubmissionBasicInfo(); @@ -90,7 +90,7 @@ class SubmissionInformationModal extends React.Component {
- {(!submissionBasicInfo.finalScore && submissionBasicInfo.finalScore !== 0) || !isReviewPhaseComplete ? '-' : submissionBasicInfo.finalScore} + {(!submissionBasicInfo.finalScore && submissionBasicInfo.finalScore !== 0) || !showFinalResults ? '-' : submissionBasicInfo.finalScore}
{ - if (!isReviewPhaseComplete) { + if (!showFinalResults) { return 'N/A'; } if (finalScoreValue === null) { @@ -139,7 +139,7 @@ export default function SubmissionHistoryRow({ SubmissionHistoryRow.defaultProps = { finalScore: null, provisionalScore: null, - isReviewPhaseComplete: false, + showFinalResults: false, isLoggedIn: false, createdAt: null, submissionTime: null, @@ -169,7 +169,7 @@ SubmissionHistoryRow.propTypes = { PT.oneOf([null]), ]), challengeStatus: PT.string.isRequired, - isReviewPhaseComplete: PT.bool, + showFinalResults: PT.bool, auth: PT.shape().isRequired, numWinners: PT.number.isRequired, submissionId: PT.string.isRequired, diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 65fd838b0..921c73d2b 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -20,7 +20,7 @@ import style from './style.scss'; export default function SubmissionRow({ isMM, isRDM, openHistory, member, submissions, toggleHistory, challengeStatus, - isReviewPhaseComplete, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, + showFinalResults, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, numWinners, auth, isLoggedIn, isF2F, isBugHunt, }) { const submissionList = Array.isArray(submissions) ? submissions : []; @@ -66,7 +66,7 @@ export default function SubmissionRow({ }; const getFinalReviewResult = () => { - if (!isReviewPhaseComplete) { + if (!showFinalResults) { return 'N/A'; } if (_.isNil(finalScore)) { @@ -103,7 +103,7 @@ export default function SubmissionRow({ ? submissionMoment.format('MMM DD, YYYY HH:mm') : 'N/A'; - const finalRankDisplay = (isReviewPhaseComplete && _.isFinite(finalRank)) ? finalRank : 'N/A'; + const finalRankDisplay = (showFinalResults && _.isFinite(finalRank)) ? finalRank : 'N/A'; const provisionalRankDisplay = _.isFinite(provisionalRank) ? provisionalRank : 'N/A'; const ratingDisplay = _.isFinite(rating) ? rating : '-'; const ratingLevelStyle = `col level-${getRatingLevel(rating)}`; @@ -306,7 +306,7 @@ export default function SubmissionRow({ { submissionList.map((submissionHistory, index) => ( {}, - isReviewPhaseComplete: false, + showFinalResults: false, finalRank: null, provisionalRank: null, rating: null, @@ -390,7 +390,7 @@ SubmissionRow.propTypes = { })).isRequired, rating: PT.number, toggleHistory: PT.func, - isReviewPhaseComplete: PT.bool, + showFinalResults: PT.bool, finalRank: PT.number, provisionalRank: PT.number, onShowPopup: PT.func.isRequired, diff --git a/src/shared/components/challenge-detail/Submissions/index.jsx b/src/shared/components/challenge-detail/Submissions/index.jsx index 6082a0e94..61330f78d 100644 --- a/src/shared/components/challenge-detail/Submissions/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/index.jsx @@ -21,6 +21,7 @@ import cn from 'classnames'; import { Button } from 'topcoder-react-ui-kit'; import DateSortIcon from 'assets/images/icon-date-sort.svg'; import SortIcon from 'assets/images/icon-sort.svg'; +import { shouldShowFinalMmResults as resolveShouldShowFinalMmResults } from 'utils/challenge-detail/mm-final-results'; import { getSubmissionId } from 'utils/submissions'; import { compressFiles } from 'utils/files'; @@ -109,7 +110,7 @@ class SubmissionsComponent extends React.Component { this.getFlagFirstTry = this.getFlagFirstTry.bind(this); this.updateSortedSubmissions = this.updateSortedSubmissions.bind(this); this.sortSubmissions = this.sortSubmissions.bind(this); - this.checkIsReviewPhaseComplete = this.checkIsReviewPhaseComplete.bind(this); + this.shouldShowFinalMmResults = this.shouldShowFinalMmResults.bind(this); } componentDidMount() { @@ -208,7 +209,7 @@ class SubmissionsComponent extends React.Component { /** * Get submission sort parameter */ - getSubmissionsSortParam(isMM, isReviewPhaseComplete) { + getSubmissionsSortParam(isMM, showFinalMmResults) { const { submissionsSort, } = this.props; @@ -216,7 +217,7 @@ class SubmissionsComponent extends React.Component { if (!field) { field = 'Submission Date'; // default field for submission sorting if (isMM) { - if (isReviewPhaseComplete) { + if (showFinalMmResults) { field = 'Final Rank'; } else { field = 'Provisional Rank'; @@ -263,8 +264,8 @@ class SubmissionsComponent extends React.Component { return; } const isMM = this.isMM(); - const isReviewPhaseComplete = this.checkIsReviewPhaseComplete(); - const { field, sort } = this.getSubmissionsSortParam(isMM, isReviewPhaseComplete); + const showFinalMmResults = this.shouldShowFinalMmResults(); + const { field, sort } = this.getSubmissionsSortParam(isMM, showFinalMmResults); // For non-MM submissions that are grouped by member, we need to adjust the sorting logic const isGrouped = !isMM && submissions.length > 0 && submissions[0].submissions; @@ -366,7 +367,7 @@ class SubmissionsComponent extends React.Component { break; } case 'Final Rank': { - if (isReviewPhaseComplete) { + if (showFinalMmResults) { valueA = toRankValue(_.get(a, 'finalRank')); valueB = toRankValue(_.get(b, 'finalRank')); } @@ -409,22 +410,15 @@ class SubmissionsComponent extends React.Component { } /** - * Check if review phase complete - */ - checkIsReviewPhaseComplete() { + * Returns whether Marathon Match final results should be shown. + */ + shouldShowFinalMmResults() { const { challenge, + mmSubmissions, } = this.props; - const allPhases = challenge.phases || []; - - let isReviewPhaseComplete = false; - _.forEach(allPhases, (phase) => { - if (phase.name === 'Review' && !phase.isOpen && moment(phase.scheduledStartDate).isBefore()) { - isReviewPhaseComplete = true; - } - }); - return isReviewPhaseComplete; + return resolveShouldShowFinalMmResults(challenge, mmSubmissions); } render() { @@ -465,9 +459,9 @@ class SubmissionsComponent extends React.Component { const isMM = this.isMM(); const isRDM = checkIsRDM(challenge); const isLoggedIn = !_.isEmpty(auth.tokenV3); - const isReviewPhaseComplete = this.checkIsReviewPhaseComplete(); + const showFinalMmResults = this.shouldShowFinalMmResults(); - const { field, sort } = this.getSubmissionsSortParam(isMM, isReviewPhaseComplete); + const { field, sort } = this.getSubmissionsSortParam(isMM, showFinalMmResults); const revertSort = (sort === 'desc') ? 'asc' : 'desc'; const { @@ -1007,7 +1001,7 @@ class SubmissionsComponent extends React.Component { sortedSubmissions.map((submission, index) => ( ( ) } diff --git a/src/shared/utils/challenge-detail/mm-final-results.js b/src/shared/utils/challenge-detail/mm-final-results.js new file mode 100644 index 000000000..ba0fde954 --- /dev/null +++ b/src/shared/utils/challenge-detail/mm-final-results.js @@ -0,0 +1,64 @@ +import _ from 'lodash'; +import moment from 'moment'; + +/** + * Normalizes a displayed score or rank value into a finite number. + * + * @param {number|string|null|undefined} value score or rank candidate. + * @returns {number|null} normalized numeric value, or null when unavailable. + */ +function toFiniteScore(value) { + if (_.isNil(value) || value === '' || value === '-') { + return null; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +/** + * Returns whether the challenge review phase has already closed. + * + * @param {Object} challenge challenge detail payload. + * @returns {boolean} true when the review phase is no longer open. + */ +export function isReviewPhaseComplete(challenge = {}) { + return _.some( + challenge.phases || [], + phase => phase.name === 'Review' && !phase.isOpen && moment(phase.scheduledStartDate).isBefore(), + ); +} + +/** + * Returns whether Marathon Match final scores or ranks already exist in the + * loaded submission payload, even if the review phase is still active. + * + * @param {Array} mmSubmissions grouped Marathon Match submissions. + * @returns {boolean} true when at least one final result is available. + */ +export function hasVisibleMmFinalResults(mmSubmissions = []) { + return _.some(mmSubmissions, (entry) => { + const finalRank = toFiniteScore(_.get(entry, 'finalRank')); + if (!_.isNil(finalRank)) { + return true; + } + + return _.some(_.get(entry, 'submissions', []), (submission) => { + const finalScore = toFiniteScore(_.get(submission, 'finalScore')); + return !_.isNil(finalScore); + }); + }); +} + +/** + * Returns whether Marathon Match final results should be shown on the + * challenge detail page. + * + * @param {Object} challenge challenge detail payload. + * @param {Array} mmSubmissions grouped Marathon Match submissions. + * @returns {boolean} true when final results are ready for display. + */ +export function shouldShowFinalMmResults(challenge = {}, mmSubmissions = []) { + return isReviewPhaseComplete(challenge) + || hasVisibleMmFinalResults(mmSubmissions); +} From 9c838e55279cba3285ebcfb36a0a1ef1b0cf7b2a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 17:12:25 +1100 Subject: [PATCH 09/11] PM-4648: add missing task header regression coverage What was broken The PM-4648 header fix already handled classic, work-app, and pure V5 task payloads, but the merged regression coverage only exercised the classic and work-app cases. Root cause (if identifiable) The follow-up pure V5 task coverage from the closed PM-4648-2 branch never landed after the main behavior fix merged. What was changed Updated the challenge detail header test fixture to match the current prop shapes used by the component. Split the task action assertions so classic type-only tasks, work-app task metadata, and pure V5 task metadata are each covered explicitly. Any added/updated tests Updated __tests__/shared/components/challenge-detail/Header/index.jsx to cover classic task, work-app task, pure V5 task, and non-task challenge header actions. --- .../challenge-detail/Header/index.jsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 793fd56c4..8ffdb6648 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -21,7 +21,9 @@ function renderHeader(challengeOverrides = {}) { renderer.render(
{ - test('hides registration and submission actions for task challenges', () => { + test('hides registration and submission actions for classic task challenges', () => { const output = renderHeader({ - phases: [ - { - isOpen: false, - name: 'Registration', - scheduledEndDate: '2030-01-02T00:00:00.000Z', - scheduledStartDate: '2030-01-01T00:00:00.000Z', - }, - { - isOpen: true, - name: 'Submission', - scheduledEndDate: '2030-01-03T00:00:00.000Z', - scheduledStartDate: '2030-01-02T00:00:00.000Z', - }, - ], - task: { - isTask: true, - }, type: 'Task', }); @@ -106,7 +94,7 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).not.toContain('Submit a solution'); }); - test('hides registration and submission actions for task payloads from work app', () => { + test('hides registration and submission actions for work-app task payloads', () => { const output = renderHeader({ task: { isTask: true, @@ -118,6 +106,18 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).not.toContain('Submit a solution'); }); + test('hides registration and submission actions for pure v5 task payloads', () => { + const output = renderHeader({ + legacy: { + pureV5Task: true, + }, + }); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); + test('shows registration and submission actions for non-task challenges', () => { const output = renderHeader(); From 470f33a7589b434503bc489e039df7fb0e3ba4de Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 6 Apr 2026 12:47:04 +0530 Subject: [PATCH 10/11] PM-4720 Update to v6 --- src/shared/services/copilotOpportunities.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/services/copilotOpportunities.js b/src/shared/services/copilotOpportunities.js index b26de4d50..f0b76b19f 100644 --- a/src/shared/services/copilotOpportunities.js +++ b/src/shared/services/copilotOpportunities.js @@ -1,6 +1,6 @@ import { config } from 'topcoder-react-utils'; -const v5ApiUrl = config.API.V5; +const v6ApiUrl = config.API.V6; /** * Fetches copilot opportunities. @@ -11,7 +11,7 @@ const v5ApiUrl = config.API.V5; * @returns {Promise} The fetched data. */ export default function getCopilotOpportunities(page, pageSize = 20, sort = 'createdAt desc', noGrouping = true) { - const url = new URL(`${v5ApiUrl}/projects/copilots/opportunities`); + const url = new URL(`${v6ApiUrl}/projects/copilots/opportunities`); url.searchParams.append('page', page); url.searchParams.append('pageSize', pageSize); url.searchParams.append('sort', sort); From e1458ab25c83ad6fe48b16b7db31cb33e354afbf Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 7 Apr 2026 10:34:34 +0530 Subject: [PATCH 11/11] PM-4720 Fix page payload for copilots api --- src/shared/services/copilotOpportunities.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shared/services/copilotOpportunities.js b/src/shared/services/copilotOpportunities.js index f0b76b19f..eaaa3b0e6 100644 --- a/src/shared/services/copilotOpportunities.js +++ b/src/shared/services/copilotOpportunities.js @@ -5,14 +5,16 @@ const v6ApiUrl = config.API.V6; /** * Fetches copilot opportunities. * - * @param {number} page - Page number (1-based). + * @param {number} page - Page number (1-based; invalid or below 1 is sent as 1 for v6 API). * @param {number} pageSize - Number of items per page. * @param {string} sort - Sort order (e.g., 'createdAt desc'). * @returns {Promise} The fetched data. */ export default function getCopilotOpportunities(page, pageSize = 20, sort = 'createdAt desc', noGrouping = true) { + const pageNum = parseInt(page, 10); + const safePage = Number.isFinite(pageNum) && pageNum >= 1 ? pageNum : 1; const url = new URL(`${v6ApiUrl}/projects/copilots/opportunities`); - url.searchParams.append('page', page); + url.searchParams.append('page', safePage); url.searchParams.append('pageSize', pageSize); url.searchParams.append('sort', sort); if (noGrouping) url.searchParams.append('noGrouping', 'true');