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,