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/__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/__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/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/__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/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 ? ( diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 328310e6e..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] || {}; @@ -70,7 +71,7 @@ export default function SubmissionRow({ }; const getFinalReviewResult = () => { - if (_.isNil(finalScore)) { + if (!showFinalResults || _.isNil(finalScore)) { return 'N/A'; } if (finalScore < 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 */ @@ -742,8 +863,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': { @@ -842,6 +965,8 @@ class SubmissionsComponent extends React.Component { downloadingAll, completeSubmissions, loadingCompleteSubmissionsForChallengeId, + loadingMmSubmissionHistoryByMember, + mmSubmissionHistoryByMember, } = this.state; const sortOptionClicked = { @@ -1367,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 e5e066927..c153a5166 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 => ( @@ -1158,7 +1239,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 +1252,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); @@ -1511,6 +1592,7 @@ const mapDispatchToProps = (dispatch) => { const { includeMmSubmissions = true, includeStatistics = includeMmSubmissions, + latestOnly = false, } = options; const challengeIdStr = _.toString(challengeId); if (!challengeIdStr) { @@ -1529,7 +1611,11 @@ const mapDispatchToProps = (dispatch) => { } const challengeSubmissionsPromise = includeMmSubmissions - ? getChallengeSubmissionsService(tokenV3, challengeIdStr) + ? getChallengeSubmissionsService( + tokenV3, + challengeIdStr, + latestOnly ? { isLatest: true } : {}, + ) : Promise.resolve({ data: [] }); Promise.all([ @@ -1619,7 +1705,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 +1732,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) => { @@ -1758,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), @@ -1794,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/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); } 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,