diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa4839..9cbcf5209 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx new file mode 100644 index 000000000..8ffdb6648 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import Renderer from 'react-test-renderer/shallow'; + +import Header from 'components/challenge-detail/Header'; + +function collectText(node) { + if (typeof node === 'string') { + return [node]; + } + + if (!React.isValidElement(node)) { + return []; + } + + return React.Children.toArray(node.props.children) + .reduce((acc, child) => acc.concat(collectText(child)), []); +} + +function renderHeader(challengeOverrides = {}) { + const renderer = new Renderer(); + renderer.render( +
, + ); + + return renderer.getRenderOutput(); +} + +describe('Challenge detail header actions', () => { + test('hides registration and submission actions for classic task challenges', () => { + const output = renderHeader({ + type: 'Task', + }); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); + + test('hides registration and submission actions for work-app task payloads', () => { + const output = renderHeader({ + task: { + isTask: true, + }, + }); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); + + test('hides registration and submission actions for pure v5 task payloads', () => { + const output = renderHeader({ + legacy: { + pureV5Task: true, + }, + }); + + expect(collectText(output)).not.toContain('Register'); + expect(collectText(output)).not.toContain('Unregister'); + expect(collectText(output)).not.toContain('Submit a solution'); + }); + + test('shows registration and submission actions for non-task challenges', () => { + const output = renderHeader(); + + expect(collectText(output)).toContain('Register'); + expect(collectText(output)).toContain('Submit a solution'); + }); +}); diff --git a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx new file mode 100644 index 000000000..02f78f79b --- /dev/null +++ b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -0,0 +1,47 @@ +import { getDisplayedScores } from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; + +describe('getDisplayedScores', () => { + test('shows final scores when a system review already produced one before review completes', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 100, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 100, + }); + }); + + test('shows final scores once the review phase is complete', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 95, + }); + }); +}); diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index cff13840e..19de44a21 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -2,19 +2,38 @@ import { getDisplayWinners, isWiproRegistrationBlocked } from 'containers/challe describe('Challenge detail Wipro registration guard', () => { test('blocks Wipro members when challenge disallows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@wipro.com', false)).toBe(true); + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(true); }); test('does not block Wipro members when challenge allows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@wipro.com', true)).toBe(false); + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: true, + type: 'Challenge', + })).toBe(false); }); test('does not block non-Wipro members when challenge disallows Wipro participation', () => { - expect(isWiproRegistrationBlocked('member@example.com', false)).toBe(false); + expect(isWiproRegistrationBlocked('member@example.com', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(false); }); test('matches Wipro domain case-insensitively and ignores surrounding spaces', () => { - expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', false)).toBe(true); + expect(isWiproRegistrationBlocked(' MEMBER@WIPRO.COM ', { + wiproAllowed: false, + type: 'Challenge', + })).toBe(true); + }); + + test('does not block Wipro members for Topgear Task even when the flag is false', () => { + expect(isWiproRegistrationBlocked('member@wipro.com', { + wiproAllowed: false, + type: 'Topgear Task', + })).toBe(false); }); }); diff --git a/__tests__/shared/utils/challenge-detail/mm-final-results.test.js b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js new file mode 100644 index 000000000..b8e83b757 --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/mm-final-results.test.js @@ -0,0 +1,59 @@ +/* eslint-env jest */ +import { + hasVisibleMmFinalResults, + isReviewPhaseComplete, + shouldShowFinalMmResults, +} from '../../../../src/shared/utils/challenge-detail/mm-final-results'; + +describe('mm-final-results utilities', () => { + const activeReviewChallenge = { + phases: [ + { + isOpen: true, + name: 'Review', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }; + + it('detects when the review phase has completed', () => { + expect(isReviewPhaseComplete({ + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + })).toBe(true); + }); + + it('keeps final Marathon Match results hidden while review is active and no final score exists', () => { + expect(shouldShowFinalMmResults(activeReviewChallenge, [ + { + finalRank: null, + submissions: [ + { + finalScore: null, + }, + ], + }, + ])).toBe(false); + }); + + it('shows final Marathon Match results as soon as a final score is available', () => { + const mmSubmissions = [ + { + finalRank: 1, + submissions: [ + { + finalScore: 100, + }, + ], + }, + ]; + + expect(hasVisibleMmFinalResults(mmSubmissions)).toBe(true); + expect(shouldShowFinalMmResults(activeReviewChallenge, mmSubmissions)).toBe(true); + }); +}); diff --git a/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js new file mode 100644 index 000000000..512e1b91b --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js @@ -0,0 +1,70 @@ +/* eslint-env jest */ +import { getDisplayedScores } from '../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; + +describe('getDisplayedScores', () => { + it('shows final scores when a system review has already produced one', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 100, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 100, + }); + }); + + it('hides final scores while review is active and no final result exists yet', () => { + expect(getDisplayedScores( + { + finalScore: null, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: true, + name: 'Registration', + scheduledStartDate: '2030-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: null, + provisionalScore: 95, + }); + }); + + it('shows final scores once the review phase is complete', () => { + expect(getDisplayedScores( + { + finalScore: 100, + initialScore: 95, + provisionalScore: 0, + }, + { + phases: [ + { + isOpen: false, + name: 'Review', + scheduledStartDate: '2000-01-01T00:00:00.000Z', + }, + ], + }, + )).toEqual({ + finalScore: 100, + provisionalScore: 95, + }); + }); +}); diff --git a/__tests__/shared/utils/mm-review-summations.test.js b/__tests__/shared/utils/mm-review-summations.test.js new file mode 100644 index 000000000..a6d721896 --- /dev/null +++ b/__tests__/shared/utils/mm-review-summations.test.js @@ -0,0 +1,128 @@ +/* eslint-env jest */ +import { buildMmSubmissionData } from '../../../src/shared/utils/mm-review-summations'; + +describe('buildMmSubmissionData', () => { + it('keeps newer raw submissions that do not have review summations yet', () => { + const reviewSummations = [ + { + aggregateScore: 100, + id: 'summation-old', + isProvisional: true, + reviewedDate: '2026-03-30T11:06:00.000Z', + submissionId: 'submission-old', + submitterHandle: 'alpha', + submitterId: '1001', + submitterMaxRating: 1800, + }, + ]; + + const rawSubmissions = [ + { + createdAt: '2026-03-30T11:06:00.000Z', + id: 'submission-old', + memberId: '1001', + registrant: { + memberHandle: 'alpha', + memberId: '1001', + rating: 1800, + }, + status: 'completed', + }, + { + createdAt: '2026-03-30T11:21:00.000Z', + id: 'submission-new', + memberId: '1001', + registrant: { + memberHandle: 'alpha', + memberId: '1001', + rating: 1800, + }, + status: 'queued', + }, + ]; + + const result = buildMmSubmissionData(reviewSummations, rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0].member).toBe('alpha'); + expect(result[0].provisionalRank).toBeNull(); + expect(result[0].submissions).toHaveLength(2); + expect(result[0].submissions[0]).toEqual(expect.objectContaining({ + provisionalScore: null, + status: 'queued', + submissionId: 'submission-new', + })); + expect(result[0].submissions[1]).toEqual(expect.objectContaining({ + provisionalScore: 100, + status: 'completed', + submissionId: 'submission-old', + })); + }); + + it('builds submission rows from raw marathon match submissions when no summations exist', () => { + const rawSubmissions = [ + { + createdAt: '2026-03-30T11:13:00.000Z', + id: 'submission-only', + memberId: '1002', + registrant: { + memberHandle: 'beta', + memberId: '1002', + rating: 1500, + }, + status: 'failed', + }, + ]; + + const result = buildMmSubmissionData([], rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ + member: 'beta', + memberId: '1002', + provisionalRank: null, + finalRank: null, + registrant: expect.objectContaining({ + memberHandle: 'beta', + userId: '1002', + }), + })); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + status: 'failed', + submissionId: 'submission-only', + }), + ]); + }); + + it('prefers initial scores over stale provisional scores from raw submissions', () => { + const rawSubmissions = [ + { + createdAt: '2026-04-01T00:01:03.000Z', + finalScore: 100, + id: 'submission-stale-provisional', + initialScore: 100, + memberId: '1003', + provisionalScore: 0, + registrant: { + memberHandle: 'gamma', + memberId: '1003', + rating: 1700, + }, + status: 'queued', + }, + ]; + + const result = buildMmSubmissionData([], rawSubmissions); + + expect(result).toHaveLength(1); + expect(result[0].submissions).toEqual([ + expect.objectContaining({ + finalScore: 100, + provisionalScore: 100, + status: 'queued', + submissionId: 'submission-stale-provisional', + }), + ]); + }); +}); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 4779b90c4..77c945f40 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -148,6 +148,9 @@ export default function ChallengeHeader(props) { const trackName = getTrackName(track); const typeName = getTypeName(type); + const isTaskChallenge = typeName === 'Task' + || _.get(challenge, 'task.isTask') === true + || _.get(challenge, 'legacy.pureV5Task') === true; const trackLower = trackName ? trackName.replace(' ', '-').toLowerCase() : 'design'; const eventNames = (events || []).map((event => (event.eventName || '').toUpperCase())); @@ -184,9 +187,14 @@ export default function ChallengeHeader(props) { const deadlineEnd = moment(nextPhase && phaseEndDate(nextPhase)); const currentTime = moment(); - const timeDiff = getTimeLeft(currentPhases || allOpenPhases[0], 'to go', true); + const activePhase = currentPhases || allOpenPhases[0]; + const shouldShowCurrentPhase = !isTaskChallenge + || (activePhase && !isRegistrationPhase(activePhase)); + const timeDiff = shouldShowCurrentPhase + ? getTimeLeft(activePhase, 'to go', true) + : null; - if (!timeDiff.late) { + if (timeDiff && !timeDiff.late) { timeDiff.text = timeDiff.text.replace('to go', ''); } @@ -316,6 +324,72 @@ export default function ChallengeHeader(props) { || !isActivedChallenge; const unregisterButtonDisabled = unregistering || registrationEnded || hasSubmissions || isLegacyMM; + let challengeActions = null; + + if (!isTopCrowdChallenge && !isTaskChallenge) { + challengeActions = ( +
+ {hasRegistered ? ( + + Unregister + + ) : ( + + Register + + )} + + + Submit a solution + + { + trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering + && hasSubmissions && ( + + View Submissions + + ) + } +
+ ); + } else if (isTopCrowdChallenge) { + challengeActions = ( + + View details on Topcoder platform + + + ); + } return (
@@ -435,66 +509,7 @@ export default function ChallengeHeader(props) { }
- {!isTopCrowdChallenge ? ( -
- {hasRegistered ? ( - - Unregister - - ) : ( - - Register - - )} - - - Submit a solution - - { - trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering - && hasSubmissions && ( - - View Submissions - - ) - } -
- ) : ( - - View details on Topcoder platform - - - )} + {challengeActions}
@@ -503,6 +518,7 @@ export default function ChallengeHeader(props) { {nextDeadlineMsg} { (status || '').toLowerCase() === 'active' + && timeDiff && (
{currentPhases && `${currentPhases.name} Ends In: `} diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index a17e8b913..5bae961d3 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -9,6 +9,7 @@ import moment from 'moment'; import { PrimaryButton, Modal } from 'topcoder-react-ui-kit'; import PT from 'prop-types'; import { services } from 'topcoder-react-lib'; +import { isReviewPhaseComplete } from 'utils/challenge-detail/mm-final-results'; import sortList from 'utils/challenge-detail/sort'; import { getSubmissionStatus } from 'utils/challenge-detail/submission-status'; @@ -74,6 +75,37 @@ const getSubmissionCreatedTime = (submission) => { ); }; +/** + * Returns the scores that should be displayed for a Marathon Match submission row. + * Initial score is the authoritative provisional score for MM submissions, and + * final scores become visible once review is complete or the payload already + * includes a final result during review. + * + * @param {Object} submission submission attempt shown in My Submissions. + * @param {Object} challenge challenge that owns the submission. + * @returns {{ finalScore: number|null, provisionalScore: number|null }} display-ready scores. + */ +export function getDisplayedScores(submission = {}, challenge = {}) { + const toNumericScore = (value) => { + if (_.isNil(value) || value === '' || value === '-') { + return null; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + + const initialScore = toNumericScore(_.get(submission, 'initialScore')); + const provisionalScore = toNumericScore(_.get(submission, 'provisionalScore')); + const finalScore = toNumericScore(_.get(submission, 'finalScore')); + const showFinalScore = isReviewPhaseComplete(challenge) || !_.isNil(finalScore); + + return { + finalScore: showFinalScore ? finalScore : null, + provisionalScore: !_.isNil(initialScore) ? initialScore : provisionalScore, + }; +} + class SubmissionsListView extends React.Component { constructor(props) { super(props); @@ -435,7 +467,7 @@ class SubmissionsListView extends React.Component {
{ sortedSubmissions.map((mySubmission) => { - let { finalScore, provisionalScore } = mySubmission; + let { finalScore, provisionalScore } = getDisplayedScores(mySubmission, challenge); if (_.isNumber(finalScore)) { if (finalScore > 0) { finalScore = finalScore.toFixed(2); diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx index 328dcf0cf..ea235fe43 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionInformationModal/index.jsx @@ -61,7 +61,7 @@ class SubmissionInformationModal extends React.Component { render() { const { toggleTestcase, onClose, isLoadingSubmissionInformation, - submissionInformation, isReviewPhaseComplete, + submissionInformation, showFinalResults, } = this.props; const submissionBasicInfo = isLoadingSubmissionInformation ? null : this.getSubmissionBasicInfo(); @@ -90,7 +90,7 @@ class SubmissionInformationModal extends React.Component {
- {(!submissionBasicInfo.finalScore && submissionBasicInfo.finalScore !== 0) || !isReviewPhaseComplete ? '-' : submissionBasicInfo.finalScore} + {(!submissionBasicInfo.finalScore && submissionBasicInfo.finalScore !== 0) || !showFinalResults ? '-' : submissionBasicInfo.finalScore}
{ - if (!isReviewPhaseComplete) { + if (!showFinalResults) { return 'N/A'; } if (finalScoreValue === null) { @@ -139,7 +139,7 @@ export default function SubmissionHistoryRow({ SubmissionHistoryRow.defaultProps = { finalScore: null, provisionalScore: null, - isReviewPhaseComplete: false, + showFinalResults: false, isLoggedIn: false, createdAt: null, submissionTime: null, @@ -169,7 +169,7 @@ SubmissionHistoryRow.propTypes = { PT.oneOf([null]), ]), challengeStatus: PT.string.isRequired, - isReviewPhaseComplete: PT.bool, + showFinalResults: PT.bool, auth: PT.shape().isRequired, numWinners: PT.number.isRequired, submissionId: PT.string.isRequired, diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 65fd838b0..921c73d2b 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -20,7 +20,7 @@ import style from './style.scss'; export default function SubmissionRow({ isMM, isRDM, openHistory, member, submissions, toggleHistory, challengeStatus, - isReviewPhaseComplete, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, + showFinalResults, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, numWinners, auth, isLoggedIn, isF2F, isBugHunt, }) { const submissionList = Array.isArray(submissions) ? submissions : []; @@ -66,7 +66,7 @@ export default function SubmissionRow({ }; const getFinalReviewResult = () => { - if (!isReviewPhaseComplete) { + if (!showFinalResults) { return 'N/A'; } if (_.isNil(finalScore)) { @@ -103,7 +103,7 @@ export default function SubmissionRow({ ? submissionMoment.format('MMM DD, YYYY HH:mm') : 'N/A'; - const finalRankDisplay = (isReviewPhaseComplete && _.isFinite(finalRank)) ? finalRank : 'N/A'; + const finalRankDisplay = (showFinalResults && _.isFinite(finalRank)) ? finalRank : 'N/A'; const provisionalRankDisplay = _.isFinite(provisionalRank) ? provisionalRank : 'N/A'; const ratingDisplay = _.isFinite(rating) ? rating : '-'; const ratingLevelStyle = `col level-${getRatingLevel(rating)}`; @@ -306,7 +306,7 @@ export default function SubmissionRow({ { submissionList.map((submissionHistory, index) => ( {}, - isReviewPhaseComplete: false, + showFinalResults: false, finalRank: null, provisionalRank: null, rating: null, @@ -390,7 +390,7 @@ SubmissionRow.propTypes = { })).isRequired, rating: PT.number, toggleHistory: PT.func, - isReviewPhaseComplete: PT.bool, + showFinalResults: PT.bool, finalRank: PT.number, provisionalRank: PT.number, onShowPopup: PT.func.isRequired, diff --git a/src/shared/components/challenge-detail/Submissions/index.jsx b/src/shared/components/challenge-detail/Submissions/index.jsx index 6082a0e94..61330f78d 100644 --- a/src/shared/components/challenge-detail/Submissions/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/index.jsx @@ -21,6 +21,7 @@ import cn from 'classnames'; import { Button } from 'topcoder-react-ui-kit'; import DateSortIcon from 'assets/images/icon-date-sort.svg'; import SortIcon from 'assets/images/icon-sort.svg'; +import { shouldShowFinalMmResults as resolveShouldShowFinalMmResults } from 'utils/challenge-detail/mm-final-results'; import { getSubmissionId } from 'utils/submissions'; import { compressFiles } from 'utils/files'; @@ -109,7 +110,7 @@ class SubmissionsComponent extends React.Component { this.getFlagFirstTry = this.getFlagFirstTry.bind(this); this.updateSortedSubmissions = this.updateSortedSubmissions.bind(this); this.sortSubmissions = this.sortSubmissions.bind(this); - this.checkIsReviewPhaseComplete = this.checkIsReviewPhaseComplete.bind(this); + this.shouldShowFinalMmResults = this.shouldShowFinalMmResults.bind(this); } componentDidMount() { @@ -208,7 +209,7 @@ class SubmissionsComponent extends React.Component { /** * Get submission sort parameter */ - getSubmissionsSortParam(isMM, isReviewPhaseComplete) { + getSubmissionsSortParam(isMM, showFinalMmResults) { const { submissionsSort, } = this.props; @@ -216,7 +217,7 @@ class SubmissionsComponent extends React.Component { if (!field) { field = 'Submission Date'; // default field for submission sorting if (isMM) { - if (isReviewPhaseComplete) { + if (showFinalMmResults) { field = 'Final Rank'; } else { field = 'Provisional Rank'; @@ -263,8 +264,8 @@ class SubmissionsComponent extends React.Component { return; } const isMM = this.isMM(); - const isReviewPhaseComplete = this.checkIsReviewPhaseComplete(); - const { field, sort } = this.getSubmissionsSortParam(isMM, isReviewPhaseComplete); + const showFinalMmResults = this.shouldShowFinalMmResults(); + const { field, sort } = this.getSubmissionsSortParam(isMM, showFinalMmResults); // For non-MM submissions that are grouped by member, we need to adjust the sorting logic const isGrouped = !isMM && submissions.length > 0 && submissions[0].submissions; @@ -366,7 +367,7 @@ class SubmissionsComponent extends React.Component { break; } case 'Final Rank': { - if (isReviewPhaseComplete) { + if (showFinalMmResults) { valueA = toRankValue(_.get(a, 'finalRank')); valueB = toRankValue(_.get(b, 'finalRank')); } @@ -409,22 +410,15 @@ class SubmissionsComponent extends React.Component { } /** - * Check if review phase complete - */ - checkIsReviewPhaseComplete() { + * Returns whether Marathon Match final results should be shown. + */ + shouldShowFinalMmResults() { const { challenge, + mmSubmissions, } = this.props; - const allPhases = challenge.phases || []; - - let isReviewPhaseComplete = false; - _.forEach(allPhases, (phase) => { - if (phase.name === 'Review' && !phase.isOpen && moment(phase.scheduledStartDate).isBefore()) { - isReviewPhaseComplete = true; - } - }); - return isReviewPhaseComplete; + return resolveShouldShowFinalMmResults(challenge, mmSubmissions); } render() { @@ -465,9 +459,9 @@ class SubmissionsComponent extends React.Component { const isMM = this.isMM(); const isRDM = checkIsRDM(challenge); const isLoggedIn = !_.isEmpty(auth.tokenV3); - const isReviewPhaseComplete = this.checkIsReviewPhaseComplete(); + const showFinalMmResults = this.shouldShowFinalMmResults(); - const { field, sort } = this.getSubmissionsSortParam(isMM, isReviewPhaseComplete); + const { field, sort } = this.getSubmissionsSortParam(isMM, showFinalMmResults); const revertSort = (sort === 'desc') ? 'asc' : 'desc'; const { @@ -1007,7 +1001,7 @@ class SubmissionsComponent extends React.Component { sortedSubmissions.map((submission, index) => ( ( ) } diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 3327d436b..9bd40228c 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -105,10 +105,12 @@ const WIPRO_REGISTRATION_SUPPORT_MESSAGE = 'If you think this is an error, pleas /** * Checks whether challenge registration should be blocked for Wipro members. * @param {String} email User email. - * @param {Boolean} wiproAllowed Challenge-level flag. + * @param {Object} challenge Challenge details used to determine registration policy. * @return {Boolean} */ -export function isWiproRegistrationBlocked(email, wiproAllowed) { +export function isWiproRegistrationBlocked(email, challenge = {}) { + if (getTypeName(challenge) === 'Topgear Task') return false; + const wiproAllowed = _.get(challenge, 'wiproAllowed'); if (wiproAllowed !== false) return false; return /@wipro\.com$/i.test(_.trim(email || '')); } @@ -412,9 +414,7 @@ class ChallengeDetailPageContainer extends React.Component { communityId, } = this.props; const userEmail = _.get(auth, 'user.email'); - const wiproAllowed = _.get(challenge, 'wiproAllowed'); - - if (isWiproRegistrationBlocked(userEmail, wiproAllowed)) { + if (isWiproRegistrationBlocked(userEmail, challenge)) { fireErrorMessage( WIPRO_REGISTRATION_BLOCKED_MESSAGE, WIPRO_REGISTRATION_SUPPORT_MESSAGE, @@ -1150,20 +1150,23 @@ function mapStateToProps(state, props) { const challengeId = String(props.match.params.challengeId); const cl = state.challengeListing; const { lookup: { allCountries, reviewTypes } } = state; + const challenge = state.challenge.details || {}; const reviewSummations = extractArrayFromStateSlice( state.challenge.reviewSummations, challengeId, ); + const rawChallengeSubmissions = Array.isArray(challenge.submissions) + ? challenge.submissions + : (_.get(challenge, 'submissions.data') || []); let mmSubmissions = extractArrayFromStateSlice(state.challenge.mmSubmissions, challengeId); - if (!mmSubmissions.length && reviewSummations.length) { - mmSubmissions = buildMmSubmissionData(reviewSummations); + if (reviewSummations.length || rawChallengeSubmissions.length) { + mmSubmissions = buildMmSubmissionData(reviewSummations, rawChallengeSubmissions); } const { auth } = state; let statisticsData = extractArrayFromStateSlice(state.challenge.statisticsData, challengeId); if (!hasRenderableStatisticsData(statisticsData) && reviewSummations.length) { statisticsData = buildStatisticsData(reviewSummations); } - const challenge = state.challenge.details || {}; const reviewSummationLookup = reviewSummations.length ? buildReviewSummationLookup(reviewSummations) : null; @@ -1304,6 +1307,12 @@ function mapStateToProps(state, props) { return toNumericScore(match.aggregateScore); }; + const initialScore = toNumericScore(normalizedAttempt.initialScore); + if (!_.isNil(initialScore)) { + normalizedAttempt.initialScore = initialScore; + normalizedAttempt.provisionalScore = initialScore; + } + const hasProvisionalScore = !_.isNil(normalizedAttempt.provisionalScore); const hasFinalScore = !_.isNil(normalizedAttempt.finalScore); diff --git a/src/shared/services/copilotOpportunities.js b/src/shared/services/copilotOpportunities.js index b26de4d50..eaaa3b0e6 100644 --- a/src/shared/services/copilotOpportunities.js +++ b/src/shared/services/copilotOpportunities.js @@ -1,18 +1,20 @@ import { config } from 'topcoder-react-utils'; -const v5ApiUrl = config.API.V5; +const v6ApiUrl = config.API.V6; /** * Fetches copilot opportunities. * - * @param {number} page - Page number (1-based). + * @param {number} page - Page number (1-based; invalid or below 1 is sent as 1 for v6 API). * @param {number} pageSize - Number of items per page. * @param {string} sort - Sort order (e.g., 'createdAt desc'). * @returns {Promise} The fetched data. */ export default function getCopilotOpportunities(page, pageSize = 20, sort = 'createdAt desc', noGrouping = true) { - const url = new URL(`${v5ApiUrl}/projects/copilots/opportunities`); - url.searchParams.append('page', page); + const pageNum = parseInt(page, 10); + const safePage = Number.isFinite(pageNum) && pageNum >= 1 ? pageNum : 1; + const url = new URL(`${v6ApiUrl}/projects/copilots/opportunities`); + url.searchParams.append('page', safePage); url.searchParams.append('pageSize', pageSize); url.searchParams.append('sort', sort); if (noGrouping) url.searchParams.append('noGrouping', 'true'); diff --git a/src/shared/utils/challenge-detail/mm-final-results.js b/src/shared/utils/challenge-detail/mm-final-results.js new file mode 100644 index 000000000..ba0fde954 --- /dev/null +++ b/src/shared/utils/challenge-detail/mm-final-results.js @@ -0,0 +1,64 @@ +import _ from 'lodash'; +import moment from 'moment'; + +/** + * Normalizes a displayed score or rank value into a finite number. + * + * @param {number|string|null|undefined} value score or rank candidate. + * @returns {number|null} normalized numeric value, or null when unavailable. + */ +function toFiniteScore(value) { + if (_.isNil(value) || value === '' || value === '-') { + return null; + } + + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +/** + * Returns whether the challenge review phase has already closed. + * + * @param {Object} challenge challenge detail payload. + * @returns {boolean} true when the review phase is no longer open. + */ +export function isReviewPhaseComplete(challenge = {}) { + return _.some( + challenge.phases || [], + phase => phase.name === 'Review' && !phase.isOpen && moment(phase.scheduledStartDate).isBefore(), + ); +} + +/** + * Returns whether Marathon Match final scores or ranks already exist in the + * loaded submission payload, even if the review phase is still active. + * + * @param {Array} mmSubmissions grouped Marathon Match submissions. + * @returns {boolean} true when at least one final result is available. + */ +export function hasVisibleMmFinalResults(mmSubmissions = []) { + return _.some(mmSubmissions, (entry) => { + const finalRank = toFiniteScore(_.get(entry, 'finalRank')); + if (!_.isNil(finalRank)) { + return true; + } + + return _.some(_.get(entry, 'submissions', []), (submission) => { + const finalScore = toFiniteScore(_.get(submission, 'finalScore')); + return !_.isNil(finalScore); + }); + }); +} + +/** + * Returns whether Marathon Match final results should be shown on the + * challenge detail page. + * + * @param {Object} challenge challenge detail payload. + * @param {Array} mmSubmissions grouped Marathon Match submissions. + * @returns {boolean} true when final results are ready for display. + */ +export function shouldShowFinalMmResults(challenge = {}, mmSubmissions = []) { + return isReviewPhaseComplete(challenge) + || hasVisibleMmFinalResults(mmSubmissions); +} diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js index 89071b46b..27a468220 100644 --- a/src/shared/utils/mm-review-summations.js +++ b/src/shared/utils/mm-review-summations.js @@ -77,6 +77,81 @@ function getSummationRating(summation) { return _.isNil(rating) ? null : rating; } +function getSubmissionHandle(submission) { + const handle = _.get(submission, 'registrant.memberHandle') + || _.get(submission, 'memberHandle') + || _.get(submission, 'createdBy'); + + if (!handle || !_.isString(handle) || !handle.trim()) { + return 'unknown'; + } + + return handle; +} + +function getSubmissionMemberId(submission) { + const memberId = _.get(submission, 'memberId', _.get(submission, 'registrant.memberId')); + return _.isNil(memberId) ? null : _.toString(memberId); +} + +function getSubmissionRating(submission) { + const rating = _.get(submission, 'rating', _.get(submission, 'registrant.rating')); + return _.isNil(rating) ? null : rating; +} + +function getSubmissionTimestamp(submission) { + const candidates = [ + _.get(submission, 'submissionTime'), + _.get(submission, 'created'), + _.get(submission, 'createdAt'), + _.get(submission, 'submittedDate'), + _.get(submission, 'reviewedDate'), + _.get(submission, 'updated'), + _.get(submission, 'updatedAt'), + ]; + return _.find(candidates, value => !!value) || null; +} + +function getSubmissionIdentifier(submission, index, handle) { + const rawSubmissionId = _.get( + submission, + 'submissionId', + _.get(submission, 'id'), + ); + + return rawSubmissionId + ? _.toString(rawSubmissionId) + : `unknown-${handle}-${index}`; +} + +function dedupeReviewSummations(reviewSummations = []) { + const deduped = []; + const seen = new Set(); + + reviewSummations.forEach((summation, index) => { + if (!summation) { + return; + } + + const dedupeKey = _.toString(_.get(summation, 'id', '')).trim() + || `${_.toString(_.get(summation, 'submissionId', '')).trim()}::${_.toString(_.get(summation, 'legacySubmissionId', '')).trim()}::${_.toString(_.get(summation, 'submitterId', '')).trim()}::${_.toString(_.get(summation, 'aggregateScore', '')).trim()}::${_.toString(_.get(summation, 'reviewedDate', '')).trim()}::${index}`; + + if (seen.has(dedupeKey)) { + return; + } + + seen.add(dedupeKey); + deduped.push(summation); + }); + + return deduped; +} + +function normalizeSubmissionStatus(status) { + const normalizedStatus = _.toLower(_.toString(status || '').trim()); + return normalizedStatus || 'completed'; +} + function ensureSubmissionEntry( existingEntry, { submissionId, timestamp, timestampValue }, @@ -241,6 +316,77 @@ function updateSubmissionEntry( }; } +function updateSubmissionEntryFromSubmission( + existingEntry, + { + submissionId, + timestamp, + timestampValue, + provisionalScore, + finalScore, + status, + isLatest, + reviewSummations, + }, +) { + const baseEntry = ensureSubmissionEntry(existingEntry, { + submissionId, + timestamp, + timestampValue, + }); + + let { submissionTime, latestTimestamp } = baseEntry; + let submissionIsLatest = baseEntry.isLatest; + + if (timestampValue > latestTimestamp) { + latestTimestamp = timestampValue; + submissionTime = timestamp || submissionTime; + } else if (!submissionTime && timestamp) { + submissionTime = timestamp; + } + + if (!_.isNil(isLatest)) { + submissionIsLatest = Boolean(isLatest); + } + + const provisionalResult = _.isNil(provisionalScore) + ? { meta: baseEntry.provisionalMeta, value: baseEntry.provisionalScore } + : mergeScoreData( + baseEntry.provisionalMeta, + baseEntry.provisionalScore, + provisionalScore, + timestampValue, + ); + + const finalResult = _.isNil(finalScore) + ? { meta: baseEntry.finalMeta, value: baseEntry.finalScore } + : mergeScoreData( + baseEntry.finalMeta, + baseEntry.finalScore, + finalScore, + timestampValue, + ); + + const mergedReviewSummations = dedupeReviewSummations([ + ...baseEntry.reviewSummations, + ...(Array.isArray(reviewSummations) ? reviewSummations : []), + ]); + + return { + ...baseEntry, + submissionTime, + latestTimestamp, + isLatest: submissionIsLatest, + provisionalMeta: provisionalResult.meta, + provisionalScore: provisionalResult.value, + finalMeta: finalResult.meta, + finalScore: finalResult.value, + status: normalizeSubmissionStatus(status || baseEntry.status), + reviewSummations: mergedReviewSummations, + reviewSummation: [...mergedReviewSummations], + }; +} + function assignRanks(members, scoreKey, rankKey, options = {}) { const { tieBreaker } = options; @@ -352,14 +498,32 @@ function updateStatisticsSubmission( }; } -export function buildMmSubmissionData(reviewSummations = []) { - if (!Array.isArray(reviewSummations) || !reviewSummations.length) { +/** + * Builds Marathon Match submission rows from review summations and raw challenge + * submission records. + * + * Raw submissions are merged so the UI can still render attempts that have not + * produced review summations yet. + * + * @param {Array} reviewSummations review summations returned by review API + * @param {Array} rawSubmissions raw challenge submissions returned by challenge details + * @returns {Array} member-grouped Marathon Match submission rows + */ +export function buildMmSubmissionData(reviewSummations = [], rawSubmissions = []) { + const normalizedReviewSummations = Array.isArray(reviewSummations) + ? reviewSummations + : []; + const normalizedRawSubmissions = Array.isArray(rawSubmissions) + ? rawSubmissions + : []; + + if (!normalizedReviewSummations.length && !normalizedRawSubmissions.length) { return []; } const membersByHandle = new Map(); - reviewSummations.forEach((summation, index) => { + normalizedReviewSummations.forEach((summation, index) => { if (!summation) { return; } @@ -424,9 +588,72 @@ export function buildMmSubmissionData(reviewSummations = []) { memberEntry.submissionsMap.set(submissionId, updatedEntry); }); + normalizedRawSubmissions.forEach((submission, index) => { + if (!submission) { + return; + } + + const handle = getSubmissionHandle(submission); + if (!membersByHandle.has(handle)) { + membersByHandle.set(handle, { + handle, + memberId: null, + rating: null, + submissionsMap: new Map(), + }); + } + + const memberEntry = membersByHandle.get(handle); + const memberId = getSubmissionMemberId(submission); + if (!memberEntry.memberId && memberId) { + memberEntry.memberId = memberId; + } + + const rating = getSubmissionRating(submission); + if (_.isNil(memberEntry.rating) && !_.isNil(rating)) { + memberEntry.rating = rating; + } + + const submissionId = getSubmissionIdentifier(submission, index, handle); + const timestamp = getSubmissionTimestamp(submission); + const timestampValue = toTimestampValue(timestamp); + const provisionalScore = normalizeScoreValue( + _.get(submission, 'initialScore', _.get(submission, 'provisionalScore')), + ); + const finalScore = normalizeScoreValue(_.get(submission, 'finalScore')); + const isLatest = _.isNil(submission.isLatest) + ? null + : Boolean(submission.isLatest); + const reviewSummation = dedupeReviewSummations([ + ...(Array.isArray(_.get(submission, 'reviewSummations')) + ? _.get(submission, 'reviewSummations') + : []), + ...(Array.isArray(_.get(submission, 'reviewSummation')) + ? _.get(submission, 'reviewSummation') + : []), + ]); + + const updatedEntry = updateSubmissionEntryFromSubmission( + memberEntry.submissionsMap.get(submissionId), + { + submissionId, + timestamp, + timestampValue, + provisionalScore, + finalScore, + status: _.get(submission, 'status'), + isLatest, + reviewSummations: reviewSummation, + }, + ); + + memberEntry.submissionsMap.set(submissionId, updatedEntry); + }); + const members = Array.from(membersByHandle.values()).map((memberEntry) => { const submissions = Array.from(memberEntry.submissionsMap.values()) .map(submission => ({ + id: submission.submissionId, submissionId: submission.submissionId, submissionTime: submission.submissionTime, isLatest: submission.isLatest,