From db6841b1e724f01d5f8ae65f44c0fc08ffca3052 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 1 Jul 2026 16:27:11 +1000 Subject: [PATCH 1/5] PM-5496: Force login for grouped challenge details What was broken Anonymous users opening a group-protected challenge details URL could land on the existing not-found/access-denied state instead of being sent through login first. Root cause (if identifiable) The challenge details load treats anonymous group-access failures as a generic failed challenge load, and the UI did not redirect anonymous users when a challenge payload or access error showed group protection. What was changed Added challenge-detail helpers to detect grouped challenge payloads and anonymous group-access failures, then redirect anonymous users to Topcoder login while preserving the original challenge URL. Authenticated users still follow the existing group membership access check. Any added/updated tests Added challenge-detail tests for grouped challenge detection, anonymous access-error detection, and login URL return-path preservation. --- .../containers/challenge-detail/index.jsx | 53 +++++++++- .../containers/challenge-detail/index.jsx | 100 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index 19de44a21..5fef65aa9 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -1,4 +1,12 @@ -import { getDisplayWinners, isWiproRegistrationBlocked } from 'containers/challenge-detail'; +import { + buildChallengeLoginUrl, + getDisplayWinners, + isGroupedChallenge, + isGroupedChallengeAccessError, + isWiproRegistrationBlocked, + shouldLoginForGroupedChallenge, + shouldLoginForGroupedChallengeError, +} from 'containers/challenge-detail'; describe('Challenge detail Wipro registration guard', () => { test('blocks Wipro members when challenge disallows Wipro participation', () => { @@ -67,3 +75,46 @@ describe('Challenge detail winners filter', () => { ]); }); }); + +describe('Challenge detail grouped challenge login guard', () => { + beforeEach(() => { + document.cookie = 'tc_utm=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + }); + + test('detects grouped challenge payloads from array or map groups', () => { + expect(isGroupedChallenge({ groups: ['group-id'] })).toBe(true); + expect(isGroupedChallenge({ groups: { 'group-id': true } })).toBe(true); + expect(isGroupedChallenge({ groups: [] })).toBe(false); + expect(isGroupedChallenge({ groups: {} })).toBe(false); + }); + + test('requires login only for anonymous grouped challenge payloads', () => { + expect(shouldLoginForGroupedChallenge({}, { groups: ['group-id'] })).toBe(true); + expect(shouldLoginForGroupedChallenge({ tokenV3: 'token' }, { groups: ['group-id'] })) + .toBe(false); + expect(shouldLoginForGroupedChallenge({}, { groups: [] })).toBe(false); + }); + + test('detects grouped challenge access errors for anonymous detail requests', () => { + expect(isGroupedChallengeAccessError({ payload: new Error('Forbidden') })).toBe(true); + expect(isGroupedChallengeAccessError(new Error('You do not have access to this group'))) + .toBe(true); + expect(isGroupedChallengeAccessError(new Error('Not Found'))).toBe(false); + + expect(shouldLoginForGroupedChallengeError({}, { payload: new Error('Forbidden') })) + .toBe(true); + expect(shouldLoginForGroupedChallengeError({ tokenV3: 'token' }, new Error('Forbidden'))) + .toBe(false); + }); + + test('builds a login URL that preserves the original challenge URL', () => { + const retUrl = 'https://www.topcoder.com/challenges/abc?tab=details#timeline'; + const loginUrl = buildChallengeLoginUrl(retUrl); + const parsedUrl = new URL(loginUrl); + + expect(`${parsedUrl.origin}${parsedUrl.pathname}`) + .toBe('https://accounts-auth0.topcoder-dev.com/member'); + expect(parsedUrl.searchParams.get('retUrl')).toBe(retUrl); + expect(parsedUrl.searchParams.get('utm_source')).toBe('community-app-main'); + }); +}); diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index e5e066927..f220a3ad7 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -139,6 +139,87 @@ export function getDisplayWinners(challenge = {}) { }); } +/** + * Checks whether a challenge has associated groups. + * @param {Object} challenge Challenge details loaded by the challenge service. + * @return {Boolean} True when the challenge has one or more group IDs. + * @throws {Error} This function does not throw. + */ +export function isGroupedChallenge(challenge = {}) { + return !_.isEmpty(_.get(challenge, 'groups')); +} + +/** + * Checks whether an anonymous user should be forced through login before + * viewing a grouped challenge. + * @param {Object} auth Authentication state from Redux. + * @param {Object} challenge Challenge details loaded by the challenge service. + * @return {Boolean} True when the user is anonymous and the challenge is grouped. + * @throws {Error} This function does not throw. + */ +export function shouldLoginForGroupedChallenge(auth = {}, challenge = {}) { + return !_.get(auth, 'tokenV3') && isGroupedChallenge(challenge); +} + +/** + * Checks whether a challenge details error represents anonymous access to a + * grouped/private challenge. + * @param {Object|Error} error Error or Redux action returned by the details load. + * @return {Boolean} True when the error indicates grouped challenge access denial. + * @throws {Error} This function does not throw. + */ +export function isGroupedChallengeAccessError(error = {}) { + const message = _.toString( + _.get(error, 'payload.message') + || _.get(error, 'message') + || _.get(error, 'payload') + || error, + ); + + return /Forbidden|access to this group/i.test(message); +} + +/** + * Checks whether an anonymous failed challenge details request should force + * login before showing the existing not-found/access-denied state. + * @param {Object} auth Authentication state from Redux. + * @param {Object|Error} error Error or Redux action returned by the details load. + * @return {Boolean} True when the anonymous request failed on grouped access. + * @throws {Error} This function does not throw. + */ +export function shouldLoginForGroupedChallengeError(auth = {}, error = {}) { + return !_.get(auth, 'tokenV3') && isGroupedChallengeAccessError(error); +} + +/** + * Builds a Topcoder login URL that returns users to the challenge details URL. + * @param {String} retUrl Original challenge details URL to return to after login. + * @param {String} utmSource UTM source to add to the login URL. + * @return {String} Login URL with encoded return URL and UTM source. + * @throws {Error} This function does not throw. + */ +export function buildChallengeLoginUrl(retUrl, utmSource = 'community-app-main') { + return appendUtmParamsToUrl( + `${config.URL.AUTH}/member?retUrl=${encodeURIComponent(retUrl)}`, + { utm_source: utmSource }, + ); +} + +/** + * Redirects the browser to login and preserves the current challenge details URL. + * @param {String} utmSource UTM source to add to the login URL. + * @return {Boolean} True when a browser redirect was initiated. + * @throws {Error} This function does not throw. + */ +export function redirectToChallengeLogin(utmSource = 'community-app-main') { + if (typeof window === 'undefined') { + return false; + } + + window.location.href = buildChallengeLoginUrl(window.location.href, utmSource); + return true; +} + function hasRenderableStatisticsData(statisticsData) { return Array.isArray(statisticsData) && statisticsData.some(entry => ( @@ -1619,7 +1700,19 @@ const mapDispatchToProps = (dispatch) => { dispatch(a.getDetailsInit(challengeId)); dispatch(a.getDetailsDone(challengeId, tokens.tokenV3, tokens.tokenV2)) .then((res) => { + if (res.error) { + if (shouldLoginForGroupedChallengeError(tokens, res)) { + redirectToChallengeLogin(); + } + return res; + } + const ch = res.payload; + if (shouldLoginForGroupedChallenge(tokens, ch)) { + redirectToChallengeLogin(); + return res; + } + const chTrack = (ch && ch.track && ch.track.name) ? ch.track.name : ch.track; if (chTrack === COMPETITION_TRACKS.DES) { const p = ch.phases || [] @@ -1634,6 +1727,13 @@ const mapDispatchToProps = (dispatch) => { dispatch(a.loadResultsDone(ch.id)); } else dispatch(a.dropResults()); return res; + }) + .catch((err) => { + if (shouldLoginForGroupedChallengeError(tokens, err)) { + redirectToChallengeLogin(); + return err; + } + throw err; }); }, registerForChallenge: (auth, challengeId) => { From 8d7e4001af26bb1abde4f758c3aa97c702712d5f Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 1 Jul 2026 16:35:52 +1000 Subject: [PATCH 2/5] PM-5495: Remove Wipro payment section from challenge details What was broken Wipro and Topgear group challenge detail pages rendered an extra Payments section with Wipro payroll and WCP payment terms. Root cause The challenge detail specification component appended a hard-coded Payments article whenever the challenge belonged to the Wipro community group. What was changed Removed the Wipro-only Payments article from the challenge detail specification while keeping the Wipro community detection for existing sidebar behavior. Any added/updated tests Added a challenge detail specification regression test that renders a Wipro group challenge and verifies the payment section copy is absent. --- .../challenge-detail/Specification/index.jsx | 43 +++++++++++++++++++ .../challenge-detail/Specification/index.jsx | 19 -------- 2 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 __tests__/shared/components/challenge-detail/Specification/index.jsx diff --git a/__tests__/shared/components/challenge-detail/Specification/index.jsx b/__tests__/shared/components/challenge-detail/Specification/index.jsx new file mode 100644 index 000000000..92d004c73 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Specification/index.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; + +import { SPECS_TAB_STATES } from 'actions/page/challenge-details'; +import ChallengeDetailsView from 'components/challenge-detail/Specification'; + +describe('Challenge detail specification Wipro payments', () => { + test('does not render the payment terms section for Wipro group challenges', () => { + const renderer = new Renderer(); + renderer.render(( + + )); + + const renderedText = JSON.stringify(renderer.getRenderOutput()); + + expect(renderedText).toContain('Challenge Overview'); + expect(renderedText).not.toContain('Payments'); + expect(renderedText).not.toContain('For employees of Wipro Technologies'); + }); +}); diff --git a/src/shared/components/challenge-detail/Specification/index.jsx b/src/shared/components/challenge-detail/Specification/index.jsx index 4c8eedbb5..244670a25 100644 --- a/src/shared/components/challenge-detail/Specification/index.jsx +++ b/src/shared/components/challenge-detail/Specification/index.jsx @@ -343,25 +343,6 @@ export default function ChallengeDetailsView(props) { ) } - {isWipro && ( -
-

- Payments -

-
-

- For employees of Wipro Technologies, following are the payment terms. - Winner(s) will be awarded the reward money/Winner Circle Points (WCPs) on - successful completion and acceptance of the submission by the stakeholder. - Accumulated reward money for the month will be paid through Wipro payroll as part of subsequent - month’s salary (eg. Aug month challenge winners payment will be credited as part of Sept month salary). - WCPs will be credited to winner’s WCP wallet in 3-4 weeks post challenge closure. - For payment of reward money/WCPs, respective country currency conversion will be - considered as per Wipro standard currency conversion guidelines. -

-
-
- )} { !isTopCrowdChallenge ? ( From 5688509257248d9068b4dd8237536bc3ad251667 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 1 Jul 2026 16:47:07 +1000 Subject: [PATCH 3/5] PM-5481: Fix MM submissions leaderboard visibility What was broken - The MM submissions tab could collapse back to the capped submissions embedded in challenge details, so authenticated unregistered users saw only submitters from the recent submissions page. - Final MM ranks and scores could render during an open submission phase when raw submission payloads included finalScore values. Root cause (if identifiable) - mapStateToProps rebuilt mmSubmissions from challenge.submissions whenever review summations existed, ignoring the fully paginated MM submissions already fetched into state. - Final result visibility treated any loaded final score or rank as displayable without checking whether submissions were still open. What was changed - Prefer the fully fetched mmSubmissions state before falling back to challenge details data. - Hide MM final ranks and scores while any submission phase is open, and prevent hidden final scores from affecting sort order. Any added/updated tests - Added regression coverage for preserving fully fetched MM submitters when review summations exist. - Added final result visibility coverage for open submission phases. --- .../containers/challenge-detail/index.jsx | 138 +++++++++++++++++- .../challenge-detail/mm-final-results.test.js | 23 +++ .../Submissions/SubmissionRow/index.jsx | 2 +- .../challenge-detail/Submissions/index.jsx | 6 +- .../containers/challenge-detail/index.jsx | 4 +- .../challenge-detail/mm-final-results.js | 7 +- 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index 19de44a21..32e9b060f 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -1,4 +1,8 @@ -import { getDisplayWinners, isWiproRegistrationBlocked } from 'containers/challenge-detail'; +import { + getDisplayWinners, + isWiproRegistrationBlocked, + mapStateToProps, +} from 'containers/challenge-detail'; describe('Challenge detail Wipro registration guard', () => { test('blocks Wipro members when challenge disallows Wipro participation', () => { @@ -67,3 +71,135 @@ describe('Challenge detail winners filter', () => { ]); }); }); + +describe('Challenge detail MM submissions state mapping', () => { + function createState() { + return { + auth: { + user: {}, + }, + challenge: { + details: { + id: 'challenge-id', + registrants: [ + { + memberHandle: 'alpha', + memberId: '101', + rating: 1200, + }, + { + memberHandle: 'beta', + memberId: '102', + rating: 1500, + }, + ], + submissions: [ + { + createdAt: '2026-06-29T01:00:00.000Z', + id: 'raw-alpha', + memberId: '101', + registrant: { + memberHandle: 'alpha', + memberId: '101', + }, + }, + ], + }, + mmSubmissions: { + challengeId: 'challenge-id', + data: [ + { + finalRank: null, + member: 'alpha', + memberId: '101', + provisionalRank: 1, + submissions: [ + { + finalScore: null, + provisionalScore: 75, + status: 'completed', + submissionId: 'raw-alpha', + submissionTime: '2026-06-29T01:00:00.000Z', + }, + ], + }, + { + finalRank: null, + member: 'beta', + memberId: '102', + provisionalRank: 2, + submissions: [ + { + finalScore: null, + provisionalScore: 70, + status: 'completed', + submissionId: 'full-beta', + submissionTime: '2026-06-28T01:00:00.000Z', + }, + ], + }, + ], + }, + reviewSummations: { + challengeId: 'challenge-id', + data: [ + { + aggregateScore: 75, + id: 'summation-alpha', + isProvisional: true, + reviewedDate: '2026-06-29T01:10:00.000Z', + submissionId: 'raw-alpha', + submitterHandle: 'alpha', + submitterId: '101', + }, + ], + }, + statisticsData: [], + checkpoints: {}, + }, + challengeListing: { + challengeTypes: [], + challengeTypesMap: {}, + openForRegistrationChallenges: {}, + }, + lookup: { + allCountries: [], + reviewTypes: [], + }, + page: { + challengeDetails: { + checkpoints: {}, + feedbackOpen: {}, + }, + }, + tcCommunities: { + list: { + data: [], + loadingUuid: '', + timestamp: 0, + }, + }, + terms: { + loadingTermsForEntity: null, + terms: [], + }, + topcoderHeader: {}, + }; + } + + test('keeps fully fetched MM submitters when review summations are present', () => { + const props = mapStateToProps(createState(), { + challengesUrl: '/challenges', + match: { + params: { + challengeId: 'challenge-id', + }, + }, + }); + + expect(props.mmSubmissions.map(submission => submission.member)).toEqual([ + 'alpha', + 'beta', + ]); + }); +}); diff --git a/__tests__/shared/utils/challenge-detail/mm-final-results.test.js b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js index b8e83b757..2855fa50e 100644 --- a/__tests__/shared/utils/challenge-detail/mm-final-results.test.js +++ b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js @@ -41,6 +41,29 @@ describe('mm-final-results utilities', () => { ])).toBe(false); }); + it('keeps final Marathon Match results hidden while submissions are still open', () => { + const submissionPhaseChallenge = { + phases: [ + { + isOpen: true, + name: 'Submission', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }; + + expect(shouldShowFinalMmResults(submissionPhaseChallenge, [ + { + finalRank: 1, + submissions: [ + { + finalScore: 100, + }, + ], + }, + ])).toBe(false); + }); + it('shows final Marathon Match results as soon as a final score is available', () => { const mmSubmissions = [ { diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 328310e6e..3ed8f963c 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -70,7 +70,7 @@ export default function SubmissionRow({ }; const getFinalReviewResult = () => { - if (_.isNil(finalScore)) { + if (!showFinalResults || _.isNil(finalScore)) { return 'N/A'; } if (finalScore < 0) { diff --git a/src/shared/components/challenge-detail/Submissions/index.jsx b/src/shared/components/challenge-detail/Submissions/index.jsx index a275aa0bb..d9138671c 100644 --- a/src/shared/components/challenge-detail/Submissions/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/index.jsx @@ -742,8 +742,10 @@ class SubmissionsComponent extends React.Component { break; } case 'Final Score': { - valueA = toScoreValue(getFinalScore(primaryA)); - valueB = toScoreValue(getFinalScore(primaryB)); + if (showFinalMmResults) { + valueA = toScoreValue(getFinalScore(primaryA)); + valueB = toScoreValue(getFinalScore(primaryB)); + } break; } case 'Provisional Score': { diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index e5e066927..ed093b83b 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -1158,7 +1158,7 @@ function getLatestReviewSummationScore(summations = [], targetType = null) { return latest ? latest.score : null; } -function mapStateToProps(state, props) { +export function mapStateToProps(state, props) { const challengeId = String(props.match.params.challengeId); const cl = state.challengeListing; const { lookup: { allCountries, reviewTypes } } = state; @@ -1171,7 +1171,7 @@ function mapStateToProps(state, props) { ? challenge.submissions : (_.get(challenge, 'submissions.data') || []); let mmSubmissions = extractArrayFromStateSlice(state.challenge.mmSubmissions, challengeId); - if (reviewSummations.length) { + if (!mmSubmissions.length && reviewSummations.length) { mmSubmissions = buildMmSubmissionData(reviewSummations, rawChallengeSubmissions); } else if (!mmSubmissions.length && rawChallengeSubmissions.length) { mmSubmissions = buildMmSubmissionData([], rawChallengeSubmissions); diff --git a/src/shared/utils/challenge-detail/mm-final-results.js b/src/shared/utils/challenge-detail/mm-final-results.js index ba0fde954..4b94f03a1 100644 --- a/src/shared/utils/challenge-detail/mm-final-results.js +++ b/src/shared/utils/challenge-detail/mm-final-results.js @@ -1,5 +1,6 @@ import _ from 'lodash'; import moment from 'moment'; +import { hasOpenSubmissionPhase } from 'utils/challengePhases'; /** * Normalizes a displayed score or rank value into a finite number. @@ -31,7 +32,7 @@ export function isReviewPhaseComplete(challenge = {}) { /** * Returns whether Marathon Match final scores or ranks already exist in the - * loaded submission payload, even if the review phase is still active. + * loaded submission payload after submissions are closed. * * @param {Array} mmSubmissions grouped Marathon Match submissions. * @returns {boolean} true when at least one final result is available. @@ -59,6 +60,10 @@ export function hasVisibleMmFinalResults(mmSubmissions = []) { * @returns {boolean} true when final results are ready for display. */ export function shouldShowFinalMmResults(challenge = {}, mmSubmissions = []) { + if (hasOpenSubmissionPhase(challenge.phases)) { + return false; + } + return isReviewPhaseComplete(challenge) || hasVisibleMmFinalResults(mmSubmissions); } From 10b6e35e4f01850fcf7c861b8d4b42e65fab0ae7 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 2 Jul 2026 09:25:00 +1000 Subject: [PATCH 4/5] PM-5505: Keep Topgear payment validation passing What was broken Topgear challenge details previously displayed the Wipro payment terms section. The production removal and regression coverage are already present on the current develop base, but the package lint/test command was blocked by a stale unused import in the challenge-detail test suite. Root cause (if identifiable) The payment text came from a hardcoded Wipro challenge-details section. After that section was removed, the nearby challenge-detail container test still imported mapStateToProps even though the test no longer used it. What was changed Removed the unused mapStateToProps import from the challenge-detail container test so the existing PM-5505/Topgear payment regression coverage and full validation commands run cleanly. Any added/updated tests No new tests were needed because develop already includes a focused challenge-detail specification regression test asserting Wipro/Topgear payment terms are not rendered. --- __tests__/shared/containers/challenge-detail/index.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index d00171a76..5fef65aa9 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -1,5 +1,4 @@ import { - mapStateToProps, buildChallengeLoginUrl, getDisplayWinners, isGroupedChallenge, From 01e80cbf02a6286f3f39c990093dd220f963a7a1 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 2 Jul 2026 14:13:50 +1000 Subject: [PATCH 5/5] Performance updates for loading submissions in marathon matches --- __tests__/shared/services/submissions.js | 25 +++ .../shared/utils/mm-review-summations.test.js | 48 +++++ .../Submissions/SubmissionRow/index.jsx | 19 +- .../challenge-detail/Submissions/index.jsx | 186 +++++++++++++++--- .../containers/challenge-detail/index.jsx | 13 +- src/shared/services/submissions.js | 26 ++- src/shared/utils/mm-review-summations.js | 49 ++++- 7 files changed, 323 insertions(+), 43 deletions(-) diff --git a/__tests__/shared/services/submissions.js b/__tests__/shared/services/submissions.js index d05820bfa..9e2060b22 100644 --- a/__tests__/shared/services/submissions.js +++ b/__tests__/shared/services/submissions.js @@ -104,4 +104,29 @@ describe('submissions service', () => { expect(global.fetch).toHaveBeenCalledTimes(1); expect(result.data).toEqual([{ id: 'submission-only-page' }]); }); + + it('passes latest and member filters to the submissions API', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + data: [{ id: 'submission-latest' }], + meta: { + page: 1, + perPage: 100, + totalPages: 1, + }, + }), + }); + + await getChallengeSubmissions('token-v3', 'challenge-id', { + isLatest: true, + memberId: '1001', + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + `${baseUrl}?challengeId=challenge-id&perPage=500&page=1&isLatest=true&memberId=1001`, + expect.objectContaining({ method: 'GET' }), + ); + }); }); diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js index cd82a0c48..781983745 100644 --- a/__tests__/shared/utils/mm-review-summations.test.js +++ b/__tests__/shared/utils/mm-review-summations.test.js @@ -188,6 +188,54 @@ describe('buildMmSubmissionData', () => { ]); }); + it('keeps latest-only rows compact while preserving member submission count', () => { + const reviewSummations = [ + { + aggregateScore: 10, + id: 'summation-old', + isProvisional: true, + reviewedDate: '2026-04-09T04:00:00.000Z', + submissionId: 'submission-old', + submitterHandle: 'ctrucza', + submitterId: '16064986', + }, + { + aggregateScore: 20, + id: 'summation-latest', + isProvisional: true, + reviewedDate: '2026-04-09T05:00:00.000Z', + submissionId: 'submission-latest', + submitterHandle: 'ctrucza', + submitterId: '16064986', + }, + ]; + const rawSubmissions = [ + { + createdAt: '2026-04-09T05:00:55.279Z', + id: 'submission-latest', + isLatest: true, + memberId: '16064986', + submissionCount: 7, + submittedDate: '2026-04-09T05:00:55.279Z', + submitterHandle: 'ctrucza', + }, + ]; + + const result = buildMmSubmissionData(reviewSummations, rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ + member: 'ctrucza', + submissionCount: 7, + })); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + provisionalScore: 20, + submissionId: 'submission-latest', + }), + ]); + }); + it('uses reviewedDate before import createdAt for review summation times', () => { const reviewSummations = [ { diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 3ed8f963c..59dcbbde3 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -11,6 +11,7 @@ import { Modal } from 'topcoder-react-ui-kit'; import IconClose from 'assets/images/icon-close-green.svg'; import moment from 'moment'; +import LoadingIndicator from 'components/LoadingIndicator'; import FailedSubmissionTooltip from '../FailedSubmissionTooltip'; import InReview from '../../icons/in-review.svg'; import Queued from '../../icons/queued.svg'; @@ -21,7 +22,7 @@ import style from './style.scss'; export default function SubmissionRow({ isMM, isRDM, openHistory, member, submissions, toggleHistory, challengeStatus, showFinalResults, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, - numWinners, auth, isLoggedIn, isF2F, isBugHunt, + numWinners, auth, isLoggedIn, isF2F, isBugHunt, submissionCount, loadingHistory, }) { const submissionList = Array.isArray(submissions) ? submissions : []; const latestSubmission = submissionList[0] || {}; @@ -114,7 +115,9 @@ export default function SubmissionRow({ const memberLinkTarget = `${_.includes(window.origin, 'www') ? '_self' : '_blank'}`; const memberForHistory = memberHandle || memberDisplay; const latestSubmissionId = latestSubmission.submissionId || latestSubmission.id || 'N/A'; - const submissionCount = submissionList.length; + const displaySubmissionCount = _.isFinite(submissionCount) + ? submissionCount + : submissionList.length; return (
@@ -181,7 +184,7 @@ export default function SubmissionRow({ > History ( - {submissionCount} + {displaySubmissionCount} ) @@ -237,7 +240,7 @@ export default function SubmissionRow({ > History ( - {submissionCount} + {displaySubmissionCount} ) @@ -305,7 +308,9 @@ export default function SubmissionRow({
{ - submissionList.map((submissionHistory, index) => ( + loadingHistory ? ( + + ) : submissionList.map((submissionHistory, index) => ( |undefined} Resolves after the history cache updates. + */ + loadMmSubmissionHistory(submission) { + const key = getMmSubmissionHistoryKey(submission); + const memberId = _.toString(_.get(submission, 'memberId', '')).trim(); + const { auth, challenge } = this.props; + const challengeId = _.toString(_.get(challenge, 'id', '')); + + if (!key || !memberId || !challengeId) { + return undefined; + } + + const { + loadingMmSubmissionHistoryByMember, + mmSubmissionHistoryByMember, + } = this.state; + if (loadingMmSubmissionHistoryByMember[key] || mmSubmissionHistoryByMember[key]) { + return undefined; + } + + this.setState({ + loadingMmSubmissionHistoryByMember: { + ...loadingMmSubmissionHistoryByMember, + [key]: true, + }, + }); + + return getChallengeSubmissionsService(_.get(auth, 'tokenV3'), challengeId, { memberId }) + .then(({ data }) => { + if (this.unmounted) { + return; + } + const memberHistoryRows = buildMmSubmissionData([], Array.isArray(data) ? data : []); + const memberHistory = _.find( + memberHistoryRows, + row => _.toString(row.memberId || row.member) === key, + ); + const currentAttemptsById = new Map( + (submission.submissions || []).map(attempt => [ + _.toString(attempt.submissionId || attempt.id), + attempt, + ]), + ); + const historySubmissions = _.get(memberHistory, 'submissions', []) + .map((attempt) => { + const attemptId = _.toString(attempt.submissionId || attempt.id); + const existingAttempt = currentAttemptsById.get(attemptId) || {}; + return { + ...existingAttempt, + ...attempt, + finalScore: _.isNil(attempt.finalScore) + ? existingAttempt.finalScore + : attempt.finalScore, + provisionalScore: _.isNil(attempt.provisionalScore) + ? existingAttempt.provisionalScore + : attempt.provisionalScore, + }; + }); + + this.setState(prevState => ({ + loadingMmSubmissionHistoryByMember: { + ...prevState.loadingMmSubmissionHistoryByMember, + [key]: false, + }, + mmSubmissionHistoryByMember: { + ...prevState.mmSubmissionHistoryByMember, + [key]: historySubmissions, + }, + })); + }) + .catch(() => { + if (this.unmounted) { + return; + } + this.setState(prevState => ({ + loadingMmSubmissionHistoryByMember: { + ...prevState.loadingMmSubmissionHistoryByMember, + [key]: false, + }, + })); + }); + } + + /** + * Toggles a submission history modal and lazily loads MM history when needed. + * @param {String} historyKey Stable key for the submission row. + * @param {Object} submission Member-grouped submission row. + */ + handleToggleSubmissionHistory(historyKey, submission) { + const { submissionHistoryOpen, toggleSubmissionHistory } = this.props; + const isOpening = !submissionHistoryOpen[historyKey]; + toggleSubmissionHistory(historyKey); + + if (!isOpening || !this.isMM()) { + return; + } + + const loadedCount = _.get(submission, 'submissions.length', 0); + const expectedCount = Number(_.get(submission, 'submissionCount')); + if (Number.isFinite(expectedCount) && expectedCount > loadedCount) { + this.loadMmSubmissionHistory(submission); + } + } + /** * Update sorted submission array */ @@ -844,6 +965,8 @@ class SubmissionsComponent extends React.Component { downloadingAll, completeSubmissions, loadingCompleteSubmissionsForChallengeId, + loadingMmSubmissionHistoryByMember, + mmSubmissionHistoryByMember, } = this.state; const sortOptionClicked = { @@ -1369,34 +1492,43 @@ class SubmissionsComponent extends React.Component { } { isMM && ( - sortedSubmissions.map((submission, index) => ( - { toggleSubmissionHistory(index); }} - openHistory={(submissionHistoryOpen[index.toString()] || false)} - isLoadingSubmissionInformation={isLoadingSubmissionInformation} - submissionInformation={submissionInformation} - onShowPopup={this.onHandleInformationPopup} - getFlagFirstTry={this.getFlagFirstTry} - onGetFlagImageFail={onGetFlagImageFail} - submissionDetail={submission} - viewAsTable={viewAsTable} - numWinners={numWinners} - auth={auth} - isLoggedIn={isLoggedIn} - /> - )) + sortedSubmissions.map((submission) => { + const historyKey = getMmSubmissionHistoryKey(submission); + const loadedHistory = mmSubmissionHistoryByMember[historyKey]; + const rowSubmissions = loadedHistory || submission.submissions; + return ( + { + this.handleToggleSubmissionHistory(historyKey, submission); + }} + openHistory={(submissionHistoryOpen[historyKey] || false)} + loadingHistory={Boolean(loadingMmSubmissionHistoryByMember[historyKey])} + isLoadingSubmissionInformation={isLoadingSubmissionInformation} + submissionInformation={submissionInformation} + onShowPopup={this.onHandleInformationPopup} + getFlagFirstTry={this.getFlagFirstTry} + onGetFlagImageFail={onGetFlagImageFail} + submissionDetail={submission} + viewAsTable={viewAsTable} + numWinners={numWinners} + auth={auth} + isLoggedIn={isLoggedIn} + /> + ); + }) ) } { !isMM && ( - sortedSubmissions.map((memberGroup, index) => ( + sortedSubmissions.map(memberGroup => ( { toggleSubmissionHistory(index); }} - openHistory={(submissionHistoryOpen[index.toString()] || false)} + toggleHistory={() => { toggleSubmissionHistory(memberGroup.member); }} + openHistory={(submissionHistoryOpen[memberGroup.member] || false)} isLoadingSubmissionInformation={isLoadingSubmissionInformation} submissionInformation={submissionInformation} onShowPopup={this.onHandleInformationPopup} diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 56653cb81..c153a5166 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -1592,6 +1592,7 @@ const mapDispatchToProps = (dispatch) => { const { includeMmSubmissions = true, includeStatistics = includeMmSubmissions, + latestOnly = false, } = options; const challengeIdStr = _.toString(challengeId); if (!challengeIdStr) { @@ -1610,7 +1611,11 @@ const mapDispatchToProps = (dispatch) => { } const challengeSubmissionsPromise = includeMmSubmissions - ? getChallengeSubmissionsService(tokenV3, challengeIdStr) + ? getChallengeSubmissionsService( + tokenV3, + challengeIdStr, + latestOnly ? { isLatest: true } : {}, + ) : Promise.resolve({ data: [] }); Promise.all([ @@ -1858,8 +1863,8 @@ const mapDispatchToProps = (dispatch) => { dispatch(a.updateChallengeInit(uuid)); dispatch(a.updateChallengeDone(uuid, challenge, tokenV3)); }, - loadMMSubmissions: (challengeId, tokenV3) => { - dispatchReviewSummations(challengeId, tokenV3); + loadMMSubmissions: (challengeId, tokenV3, options = {}) => { + dispatchReviewSummations(challengeId, tokenV3, options); }, getSubmissionArtifacts: (submissionId, tokenV3) => getSubmissionArtifactsService(tokenV3, submissionId), @@ -1894,7 +1899,7 @@ const mapDispatchToProps = (dispatch) => { challengeId, _.get(tokens, 'tokenV3'), { - includeMmSubmissions: isMMChallenge, + includeMmSubmissions: false, includeStatistics: isMMChallenge, }, ); diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js index 4ed829d44..dfd588899 100644 --- a/src/shared/services/submissions.js +++ b/src/shared/services/submissions.js @@ -30,10 +30,21 @@ async function fetchChallengeSubmissionsPage({ challengeId, page, perPage, + filters, aggregated, meta, }) { - const url = `${v6ApiUrl}/submissions?challengeId=${encodeURIComponent(challengeId)}&perPage=${perPage}&page=${page}`; + const params = new URLSearchParams(); + params.set('challengeId', challengeId); + params.set('perPage', perPage); + params.set('page', page); + Object.keys(filters || {}).forEach((key) => { + const value = filters[key]; + if (value !== undefined && value !== null && value !== '') { + params.set(key, value); + } + }); + const url = `${v6ApiUrl}/submissions?${params.toString()}`; const response = await fetch(url, { method: 'GET', headers: getHeaders(tokenV3), @@ -65,6 +76,7 @@ async function fetchChallengeSubmissionsPage({ challengeId, page: page + 1, perPage, + filters, aggregated: combined, meta: latestMeta, }); @@ -81,17 +93,27 @@ async function fetchChallengeSubmissionsPage({ * @param {String|Number} challengeId Challenge identifier used by the submissions API. * @param {Object} options Optional pagination settings. * @param {Number} options.perPage Number of records requested per API page. + * @param {Boolean} options.isLatest When true, fetch only the latest submission per member. + * @param {String|Number} options.memberId Optional member id used to fetch one member's history. * @return {Promise<{data: Array, meta: Object}>} Aggregated submissions and * final response metadata. * @throws {Error} Throws when any submissions API page returns a non-2xx status. */ export async function getChallengeSubmissions(tokenV3, challengeId, options = {}) { - const { perPage = DEFAULT_PER_PAGE } = options; + const { + isLatest, + memberId, + perPage = DEFAULT_PER_PAGE, + } = options; const { data, meta } = await fetchChallengeSubmissionsPage({ tokenV3, challengeId, page: 1, perPage, + filters: { + isLatest: isLatest === undefined ? undefined : isLatest, + memberId, + }, aggregated: [], meta: null, }); diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index 5d5ba721f..b0a12e2db 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -178,6 +178,19 @@ function getSubmissionTimestamp(submission) { return _.find(candidates, value => !!value) || null; } +/** + * Resolves the total number of attempts for a member from a submission row. + * Latest-only submission API responses include this value so the UI can show + * History counts without loading every historical attempt upfront. + * + * @param {Object} submission Raw submission returned by the submissions API. + * @returns {Number|null} Submission count, or null when unavailable. + */ +function getSubmissionCount(submission) { + const count = Number(_.get(submission, 'submissionCount')); + return Number.isFinite(count) ? count : null; +} + function getSubmissionIdentifier(submission, index, handle) { const rawSubmissionId = _.get( submission, @@ -596,18 +609,40 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] } const membersByHandle = new Map(); + const latestRawSubmissionIds = new Set( + normalizedRawSubmissions + .filter(submission => submission && submission.isLatest === true) + .map(submission => _.toString( + _.get(submission, 'id', _.get(submission, 'submissionId', '')), + )) + .filter(id => id.length > 0), + ); + const hasLatestRawSubmissions = latestRawSubmissionIds.size > 0; normalizedReviewSummations.forEach((summation, index) => { if (!summation) { return; } + const rawSubmissionId = _.get( + summation, + 'submissionId', + _.get(summation, 'id'), + ); + if ( + hasLatestRawSubmissions + && (!rawSubmissionId || !latestRawSubmissionIds.has(_.toString(rawSubmissionId))) + ) { + return; + } + const handle = getSummationHandle(summation); if (!membersByHandle.has(handle)) { membersByHandle.set(handle, { handle, memberId: null, rating: null, + submissionCount: null, submissionsMap: new Map(), }); } @@ -624,11 +659,6 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] memberEntry.rating = rating; } - const rawSubmissionId = _.get( - summation, - 'submissionId', - _.get(summation, 'id'), - ); const submissionId = rawSubmissionId ? _.toString(rawSubmissionId) : `unknown-${handle}-${index}`; @@ -675,6 +705,7 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] handle, memberId: null, rating: null, + submissionCount: null, submissionsMap: new Map(), }); } @@ -690,6 +721,11 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] memberEntry.rating = rating; } + const submissionCount = getSubmissionCount(submission); + if (_.isNil(memberEntry.submissionCount) && !_.isNil(submissionCount)) { + memberEntry.submissionCount = submissionCount; + } + const submissionId = getSubmissionIdentifier(submission, index, handle); const timestamp = getSubmissionTimestamp(submission); const timestampValue = toTimestampValue(timestamp); @@ -819,6 +855,9 @@ export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = [] rating, provisionalRank: null, finalRank: null, + submissionCount: _.isNil(memberEntry.submissionCount) + ? submissionsWithProvisionalFallback.length + : memberEntry.submissionCount, submissions: submissionsWithProvisionalFallback, bestProvisionalScore, bestProvisionalTimestamp,