diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml deleted file mode 100644 index 1d313051c4..0000000000 --- a/.github/workflows/code_reviewer.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI PR Reviewer - -on: - pull_request: - types: - - opened - - synchronize -permissions: - pull-requests: write -jobs: - tc-ai-pr-review: - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v3 - - - name: TC AI PR Reviewer - uses: topcoder-platform/tc-ai-pr-reviewer@master - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) - LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} - exclude: "**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp" # Optional: exclude patterns separated by commas diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index c92710c07b..e35542ad68 100644 --- a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -1,4 +1,7 @@ -import { getDisplayedScores } from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; +import { + getDisplayedScores, + getSubmissionTestProgress, +} from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; describe('getDisplayedScores', () => { test('shows final scores when a system review already produced one before review completes', () => { @@ -45,3 +48,23 @@ describe('getDisplayedScores', () => { }); }); }); + +describe('getSubmissionTestProgress', () => { + it('formats marathon test progress metadata for display', () => { + expect(getSubmissionTestProgress({ + reviewSummations: [ + { + metadata: { + testProcess: 'system', + testProgress: 0.2, + testStatus: 'FAILED', + }, + }, + ], + })).toEqual({ + process: 'system', + progressPercent: '20%', + status: 'FAILED', + }); + }); +}); diff --git a/__tests__/shared/components/challenge-detail/Submissions/index.jsx b/__tests__/shared/components/challenge-detail/Submissions/index.jsx new file mode 100644 index 0000000000..e9a3bc6b30 --- /dev/null +++ b/__tests__/shared/components/challenge-detail/Submissions/index.jsx @@ -0,0 +1,92 @@ +import { + enrichChallengeSubmissions, + groupSubmissionsByMember, +} from 'components/challenge-detail/Submissions'; + +describe('challenge detail Submissions helpers', () => { + const challenge = { + registrants: [ + { + memberHandle: 'JayaApurva', + memberId: '1001', + rating: 1152, + }, + { + memberHandle: 'Om2707', + memberId: '1002', + rating: 1326, + }, + ], + }; + + it('enriches complete v6 submissions with registrants outside the embedded first page', () => { + const embeddedSubmissions = [ + { + id: 'submission-2', + memberId: '1002', + registrant: challenge.registrants[1], + }, + ]; + const fetchedSubmissions = [ + { + id: 'submission-1', + memberId: '1001', + submittedDate: '2026-05-20T10:04:00.000Z', + submitterHandle: 'JayaApurva', + submitterMaxRating: 1152, + }, + { + id: 'submission-2', + memberId: '1002', + submittedDate: '2026-05-20T18:56:00.000Z', + submitterHandle: 'Om2707', + submitterMaxRating: 1326, + }, + ]; + + const enriched = enrichChallengeSubmissions( + fetchedSubmissions, + challenge, + embeddedSubmissions, + ); + const grouped = groupSubmissionsByMember(enriched); + + expect(grouped.map(group => group.member)).toEqual(['JayaApurva', 'Om2707']); + expect(grouped[0]).toEqual(expect.objectContaining({ + rating: 1152, + submissions: [ + expect.objectContaining({ + created: '2026-05-20T10:04:00.000Z', + registrant: challenge.registrants[0], + }), + ], + })); + }); + + it('uses review summations to populate missing score fields', () => { + const enriched = enrichChallengeSubmissions([ + { + id: 'submission-1', + memberId: '1001', + submitterHandle: 'JayaApurva', + reviewSummation: [ + { + aggregateScore: 75, + isProvisional: true, + reviewedDate: '2026-05-20T10:05:00.000Z', + }, + { + aggregateScore: 82, + isFinal: true, + reviewedDate: '2026-05-20T10:06:00.000Z', + }, + ], + }, + ], challenge); + + expect(enriched[0]).toEqual(expect.objectContaining({ + finalScore: 82, + initialScore: 75, + })); + }); +}); diff --git a/__tests__/shared/services/reviewSummations.js b/__tests__/shared/services/reviewSummations.js index 27f66b3262..2a2ff8a779 100644 --- a/__tests__/shared/services/reviewSummations.js +++ b/__tests__/shared/services/reviewSummations.js @@ -67,12 +67,12 @@ describe('reviewSummations service', () => { expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenNthCalledWith( 1, - `${baseUrl}?challengeId=challenge-id&perPage=500&page=1`, + `${baseUrl}?challengeId=challenge-id&perPage=500&page=1&metadata=true`, expect.objectContaining({ method: 'GET' }), ); expect(global.fetch).toHaveBeenNthCalledWith( 2, - `${baseUrl}?challengeId=challenge-id&perPage=500&page=2`, + `${baseUrl}?challengeId=challenge-id&perPage=500&page=2&metadata=true`, expect.objectContaining({ method: 'GET' }), ); expect(result.data).toEqual([ diff --git a/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js index 512e1b91ba..211a8bdbec 100644 --- a/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js +++ b/__tests__/shared/utils/challenge-detail/my-submission-scores.test.js @@ -1,5 +1,8 @@ /* eslint-env jest */ -import { getDisplayedScores } from '../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; +import { + getDisplayedScores, + getSubmissionTestProgress, +} from '../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; describe('getDisplayedScores', () => { it('shows final scores when a system review has already produced one', () => { @@ -68,3 +71,53 @@ describe('getDisplayedScores', () => { }); }); }); + +describe('getSubmissionTestProgress', () => { + it('returns process, status, and percent from safe review summation metadata', () => { + expect(getSubmissionTestProgress({ + reviewSummations: [ + { + metadata: { + testProcess: 'provisional', + testProgress: 0.25, + testStatus: 'SUCCESS', + testProgressDetails: { + updatedAt: '2026-05-01T00:00:00.000Z', + }, + }, + }, + { + metadata: { + testProcess: 'system', + testProgress: 0.75, + testStatus: 'IN PROGRESS', + testProgressDetails: { + updatedAt: '2026-05-01T01:00:00.000Z', + }, + }, + }, + ], + })).toEqual({ + process: 'system', + progressPercent: '75%', + status: 'IN PROGRESS', + }); + }); + + it('ignores per-seed metadata and returns blank display data when progress is absent', () => { + expect(getSubmissionTestProgress({ + reviewSummation: [ + { + metadata: { + testScores: [ + { + score: 1, + seed: 123456789, + }, + ], + }, + }, + ], + })).toEqual({}); + }); +}); diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index 7a3fe1d642..32cb85f687 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -17,6 +17,8 @@ import DateSortIcon from 'assets/images/icon-date-sort.svg'; import SortIcon from 'assets/images/icon-sort.svg'; import Tooltip from 'components/Tooltip'; import IconFail from '../../icons/failed.svg'; +import IconTestInProgress from '../../icons/clock.svg'; +import IconTestSuccess from '../../icons/check-mark.svg'; import DownloadIcon from '../../../SubmissionManagement/Icons/IconSquareDownload.svg'; import ZoomIcon from '../../../SubmissionManagement/Icons/IconZoom.svg'; import ArtifactsDownloadIcon from '../../../SubmissionManagement/Icons/IconDownloadArtifacts.svg'; @@ -102,6 +104,153 @@ export function getDisplayedScores(submission = {}) { }; } +/** + * Normalizes Review API test process metadata for display. + * + * @param {String} value test process or legacy test type metadata. + * @returns {String|undefined} `provisional` or `system` when recognized. + */ +function normalizeTestProcess(value) { + const normalized = _.toLower(_.toString(value || '').trim()); + if (normalized === 'system' || normalized === 'final') { + return 'system'; + } + if (normalized === 'provisional') { + return 'provisional'; + } + return undefined; +} + +/** + * Normalizes Review API test status metadata for display. + * + * @param {String} value test status metadata. + * @returns {String|undefined} supported status value when recognized. + */ +function normalizeTestStatus(value) { + const normalized = _.toUpper(_.toString(value || '').trim()); + if (['FAILED', 'IN PROGRESS', 'SUCCESS'].indexOf(normalized) >= 0) { + return normalized; + } + return undefined; +} + +/** + * Normalizes Review API test progress metadata into the supported 0-to-1 range. + * + * @param {Number|String} value test progress metadata. + * @returns {Number|undefined} finite progress value when present. + */ +function normalizeTestProgress(value) { + const progress = _.isString(value) ? Number(value) : value; + if (!_.isFinite(progress)) { + return undefined; + } + return Math.min(Math.max(progress, 0), 1); +} + +/** + * Returns display-ready test process, status, and percent progress for a submission. + * + * @param {Object} submission submission attempt shown in My Submissions. + * @returns {Object} display process, status, and progress percentage. + * Marathon Match competitors receive only sanitized progress metadata from Review API. + */ +export function getSubmissionTestProgress(submission = {}) { + const candidates = collectReviewSummations(submission) + .map((summation) => { + const metadata = _.isObject(_.get(summation, 'metadata')) + ? _.get(summation, 'metadata') + : {}; + const process = normalizeTestProcess( + _.get(metadata, 'testProcess', _.get(metadata, 'testType')), + ); + const status = normalizeTestStatus(_.get(metadata, 'testStatus')); + const progress = normalizeTestProgress(_.get(metadata, 'testProgress')); + + if (!process && !status && _.isUndefined(progress)) { + return null; + } + + const updatedAt = _.get(metadata, 'testProgressDetails.updatedAt') + || _.get(summation, 'updatedAt') + || _.get(summation, 'createdAt') + || ''; + const updatedAtValue = new Date(updatedAt).getTime(); + + return { + process, + progress, + progressPercent: _.isUndefined(progress) + ? undefined + : `${Math.round(progress * 100)}%`, + status, + inProgressPriority: status === 'IN PROGRESS' ? 1 : 0, + processPriority: process === 'system' ? 1 : 0, + updatedAt: _.isFinite(updatedAtValue) ? updatedAtValue : 0, + }; + }) + .filter(Boolean) + .sort((a, b) => ( + b.inProgressPriority - a.inProgressPriority + || b.updatedAt - a.updatedAt + || b.processPriority - a.processPriority + )); + + if (!candidates.length) { + return {}; + } + + return { + process: candidates[0].process, + progressPercent: candidates[0].progressPercent, + status: candidates[0].status, + }; +} + +/** + * Renders a visual test status indicator. + * + * @param {String} status normalized test status. + * @returns {React.Node} status icon, or null when no status is available. + */ +function renderTestStatusIcon(status) { + if (status === 'IN PROGRESS') { + return ( + + + + ); + } + if (status === 'SUCCESS') { + return ( + + + + ); + } + if (status === 'FAILED') { + return ( + + + + ); + } + return null; +} + class SubmissionsListView extends React.Component { constructor(props) { super(props); @@ -456,6 +605,15 @@ class SubmissionsListView extends React.Component { +
+ Current Tests Process +
+
+ Test Status +
+
+ Test Progress +
ACTIONS
@@ -479,6 +637,7 @@ class SubmissionsListView extends React.Component { provisionalScore = 'N/A'; } const { isAccepted } = getSubmissionStatus(mySubmission); + const testProgress = getSubmissionTestProgress(mySubmission); const statusStyleName = isAccepted ? 'accepted' : 'queue'; const statusLabel = isAccepted ? 'Accepted' : 'In Queue'; const displaySubmissionId = getDisplaySubmissionId(mySubmission); @@ -542,6 +701,30 @@ class SubmissionsListView extends React.Component {
Time
{submissionTimeDisplay} +
+
Current Tests Process
+ {testProgress.process || ''} +
+
+
Test Status
+ {renderTestStatusIcon(testProgress.status)} +
+
+
Test Progress
+ {testProgress.progressPercent || ''} +
{ !isTopCrowdChallenge ? ( diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/styles.scss b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/styles.scss index 405b009fce..10d2a44de3 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/styles.scss +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/styles.scss @@ -126,10 +126,9 @@ button.column-1-1, .column-1-1 { - width: 35%; + width: 100%; - @include xs-to-sm { - width: 100%; + @include xs-to-md { align-items: flex-start; } } @@ -150,11 +149,25 @@ button.column-1-1, color: #888894; } - @include xs-to-sm { + @include xs-to-md { display: none !important; } } +.test-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; + width: 22px; + + svg { + display: block; + height: 20px; + width: 20px; + } +} + .submission-table-column { @include roboto-medium; @@ -174,7 +187,7 @@ button.column-1-1, } } - @include xs-to-sm { + @include xs-to-md { display: flex; flex-direction: column; justify-content: flex-start; @@ -184,7 +197,9 @@ button.column-1-1, } .submission-table-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.8fr) minmax(0, 0.8fr) minmax(0, 1.05fr) minmax(0, 1.55fr) minmax(0, 1.2fr) minmax(0, 0.85fr) minmax(0, 0.85fr) minmax(0, 0.95fr); + column-gap: 16px; border-bottom: none; height: 54px; margin-top: 5px; @@ -213,22 +228,22 @@ button.column-1-1, } .column-1 { - width: 22%; - display: flex; - justify-content: space-between; + display: contents; - @include xs-to-sm { + @include xs-to-md { + display: flex; + flex-direction: column; width: 100%; align-items: flex-start; } } .column-2 { - width: 78%; - display: flex; - justify-content: space-between; + display: contents; - @include xs-to-sm { + @include xs-to-md { + display: flex; + flex-direction: column; width: 100%; align-items: flex-start; } @@ -245,80 +260,78 @@ button.column-1-1, .column-1-2 { &.status-row { - margin-right: -16px; + margin-right: 0; } - @include xs-to-sm { + @include xs-to-md { width: 100%; align-items: flex-start; } } .column-2-1 { - width: 28%; + width: 100%; justify-content: flex-end; - padding-right: 20px; + padding-right: 12px; &.final-score-row { - margin-left: -40px; + margin-left: 0; - @include xs-to-sm { + @include xs-to-md { margin-left: 0; } } - @include xs-to-sm { - width: 100%; + @include xs-to-md { align-items: flex-start; margin-left: 0; } } .column-2-2 { - width: 18%; + width: 100%; justify-content: flex-end; - padding-right: 20px; + padding-right: 12px; &.provisional-score-row { - margin-left: -23px; + margin-left: 0; - @include xs-to-sm { + @include xs-to-md { margin-left: 0; } } - @include xs-to-sm { - width: 100%; + @include xs-to-md { align-items: flex-start; } } .column-2-3 { - width: 18%; + width: 100%; justify-content: flex-start; - padding-left: 40px; + padding-left: 16px; &.time-row { - width: 19%; - margin-left: 65px; + width: 100%; + margin-left: 0; - @include xs-to-sm { + @include xs-to-md { margin-left: 0; margin-bottom: 16px; } } - @include xs-to-sm { - width: 100%; + @include xs-to-md { align-items: flex-start; padding-left: 0; } } .column-2-4 { - width: 8%; + width: 100%; + justify-content: flex-end; - @include xs-to-sm { + @include xs-to-md { width: auto; align-items: flex-start; margin-top: 16px; @@ -329,6 +342,49 @@ button.column-1-1, } } +.column-2-5 { + width: 100%; + justify-content: flex-start; + + @include xs-to-md { + align-items: flex-start; + } +} + +.column-2-6 { + width: 100%; + justify-content: center; + + @include xs-to-md { + align-items: flex-start; + } +} + +.column-2-7 { + width: 100%; + justify-content: flex-start; + + @include xs-to-md { + align-items: flex-start; + } +} + +.test-process-row { + text-transform: lowercase; +} + +.test-status-in-progress { + color: #d97706; +} + +.test-status-success { + color: #137d60; +} + +.test-status-failed { + color: #ef476f; +} + .icon-search { margin-left: 17px; } @@ -370,7 +426,7 @@ button.column-1-1, .mobile-header { display: none; - @include xs-to-sm { + @include xs-to-md { display: block; margin-top: 16px; font-size: 11px; diff --git a/src/shared/components/challenge-detail/Submissions/index.jsx b/src/shared/components/challenge-detail/Submissions/index.jsx index 61330f78d0..a275aa0bb3 100644 --- a/src/shared/components/challenge-detail/Submissions/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/index.jsx @@ -24,6 +24,7 @@ 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'; +import { getChallengeSubmissions as getChallengeSubmissionsService } from 'services/submissions'; import sortList from 'utils/challenge-detail/sort'; import challengeDetailsActions from 'actions/page/challenge-details'; @@ -43,11 +44,270 @@ const { getProvisionalScore, getFinalScore } = submissionUtils; const { getService } = services.submissions; /** - * Groups submissions by member - * @param {Array} submissions all submissions - * @return {Array} grouped submissions by member + * Normalizes score values from submissions and review summations. + * @param {*} score Raw score value from an API response. + * @return {Number|null} Numeric score or null when the value is missing. */ -function groupSubmissionsByMember(submissions) { +function normalizeScoreValue(score) { + if (_.isNil(score) || score === '' || score === '-') { + return null; + } + const parsed = Number(score); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Resolves a submission id from the v5/v6 submission shapes used by the tab. + * @param {Object} submission Submission record. + * @return {String} Submission id, or an empty string when unavailable. + */ +function getSubmissionIdentifier(submission = {}) { + return _.toString(submission.id || submission.submissionId || '').trim(); +} + +/** + * Resolves a member id from the v5/v6 submission shapes used by the tab. + * @param {Object} submission Submission record. + * @return {String} Member id, or an empty string when unavailable. + */ +function getSubmissionMemberId(submission = {}) { + return _.toString( + submission.memberId + || _.get(submission, 'registrant.memberId') + || submission.submitterId + || '', + ).trim(); +} + +/** + * Resolves a display handle from the v5/v6 submission shapes used by the tab. + * @param {Object} submission Submission record. + * @return {String} Member handle, or an empty string when unavailable. + */ +function getSubmissionHandle(submission = {}) { + return _.toString( + _.get(submission, 'registrant.memberHandle') + || submission.memberHandle + || submission.submitterHandle + || submission.createdBy + || '', + ).trim(); +} + +/** + * Resolves the newest available submission timestamp. + * @param {Object} submission Submission record. + * @return {String|null} Timestamp string or null when unavailable. + */ +function getSubmissionTimestamp(submission = {}) { + return submission.submittedDate + || submission.submissionTime + || submission.created + || submission.createdAt + || null; +} + +/** + * Returns a submission's review summations from all supported response fields. + * @param {Object} submission Submission record. + * @return {Array} Review summations for the submission. + */ +function getSubmissionReviewSummations(submission = {}) { + return [ + ...(Array.isArray(submission.reviewSummations) ? submission.reviewSummations : []), + ...(Array.isArray(submission.reviewSummation) ? submission.reviewSummation : []), + ]; +} + +/** + * Determines whether a review summation is final, provisional, or uncategorized. + * @param {Object} summation Review summation record. + * @return {String|null} "final", "provisional", or null. + */ +function getReviewSummationType(summation = {}) { + const metadata = _.isObject(summation.metadata) ? summation.metadata : {}; + const type = _.toLower(_.toString(summation.type || '').trim()); + const stage = _.toLower(_.toString(metadata.stage || '').trim()); + const testType = _.toLower(_.toString(metadata.testType || '').trim()); + + if (summation.isFinal || summation.is_final || type === 'final' || stage === 'final') { + return 'final'; + } + if ( + summation.isProvisional + || summation.is_provisional + || type === 'provisional' + || testType === 'provisional' + ) { + return 'provisional'; + } + return null; +} + +/** + * Gets the latest score from review summations, optionally limited by type. + * @param {Array} summations Review summation records. + * @param {String|null} targetType Optional summation type to include. + * @return {Number|null} Latest score, or null when no score is available. + */ +function getLatestReviewSummationScore(summations = [], targetType = null) { + let latest = null; + + summations.forEach((summation, index) => { + const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); + if (_.isNil(score)) { + return; + } + if (targetType && getReviewSummationType(summation) !== targetType) { + return; + } + + const timestamp = _.get(summation, 'reviewedDate') + || _.get(summation, 'createdAt') + || _.get(summation, 'created') + || _.get(summation, 'updatedAt') + || ''; + const timestampValue = timestamp ? new Date(timestamp).getTime() : 0; + if (!latest + || timestampValue > latest.timestampValue + || (timestampValue === latest.timestampValue && index > latest.index)) { + latest = { + score, + timestampValue, + index, + }; + } + }); + + return latest ? latest.score : null; +} + +/** + * Builds a lookup by submission id from existing normalized submissions. + * @param {Array} submissions Existing submissions passed through container normalization. + * @return {Map} Existing submissions keyed by id. + */ +function buildExistingSubmissionLookup(submissions = []) { + const lookup = new Map(); + submissions.forEach((submission) => { + const submissionId = getSubmissionIdentifier(submission); + if (submissionId) { + lookup.set(submissionId, submission); + } + }); + return lookup; +} + +/** + * Builds registrant lookups by member id and handle. + * @param {Array} registrants Challenge registrants. + * @return {Object} Registrant lookup maps. + */ +function buildRegistrantLookup(registrants = []) { + const byMemberId = new Map(); + const byHandle = new Map(); + + registrants.forEach((registrant) => { + const memberId = _.toString(registrant.memberId || '').trim(); + const handle = _.toLower(_.toString(registrant.memberHandle || registrant.handle || '').trim()); + if (memberId) { + byMemberId.set(memberId, registrant); + } + if (handle) { + byHandle.set(handle, registrant); + } + }); + + return { + byHandle, + byMemberId, + }; +} + +/** + * Enriches paginated v6 submissions with registrants and score fields expected by the tab. + * @param {Array} fetchedSubmissions Complete submissions fetched from the v6 API. + * @param {Object} challenge Challenge details containing registrants. + * @param {Array} existingSubmissions Already-normalized submissions from challenge details. + * @return {Array} Submissions ready for display by the challenge detail tab. + */ +export function enrichChallengeSubmissions( + fetchedSubmissions = [], + challenge = {}, + existingSubmissions = [], +) { + const existingById = buildExistingSubmissionLookup(existingSubmissions); + const registrants = Array.isArray(challenge.registrants) ? challenge.registrants : []; + const registrantsLookup = buildRegistrantLookup(registrants); + + return fetchedSubmissions.map((submission) => { + const submissionId = getSubmissionIdentifier(submission); + const existing = existingById.get(submissionId) || {}; + const memberId = getSubmissionMemberId(submission) || getSubmissionMemberId(existing); + const handle = getSubmissionHandle(submission) || getSubmissionHandle(existing); + let registrant = existing.registrant || submission.registrant || null; + + if (!registrant && memberId) { + registrant = registrantsLookup.byMemberId.get(memberId) || null; + } + if (!registrant && handle) { + registrant = registrantsLookup.byHandle.get(_.toLower(handle)) || null; + } + if (!registrant && handle) { + registrant = { + memberHandle: handle, + memberId, + rating: submission.submitterMaxRating || existing.submitterMaxRating || null, + }; + } + + const reviewSummations = _.uniqBy([ + ...getSubmissionReviewSummations(existing), + ...getSubmissionReviewSummations(submission), + ], summation => _.toString(summation.id || JSON.stringify(summation))); + const existingInitialScore = normalizeScoreValue(existing.initialScore); + const existingFinalScore = normalizeScoreValue(existing.finalScore); + const initialScore = !_.isNil(existingInitialScore) + ? existingInitialScore + : getLatestReviewSummationScore(reviewSummations, 'provisional'); + const finalScoreFromSummations = getLatestReviewSummationScore(reviewSummations, 'final'); + const fallbackFinalScore = _.isNil(finalScoreFromSummations) + ? getLatestReviewSummationScore(reviewSummations) + : finalScoreFromSummations; + const finalScore = !_.isNil(existingFinalScore) ? existingFinalScore : fallbackFinalScore; + const created = getSubmissionTimestamp(submission) || getSubmissionTimestamp(existing); + const updated = submission.updated + || submission.updatedAt + || existing.updated + || existing.updatedAt + || null; + const rating = submission.rating + || existing.rating + || _.get(registrant, 'rating') + || submission.submitterMaxRating + || existing.submitterMaxRating; + + return { + ...existing, + ...submission, + created, + finalScore: _.isNil(finalScore) ? submission.finalScore : finalScore, + initialScore: _.isNil(initialScore) ? submission.initialScore : initialScore, + rating, + registrant, + reviewSummation: reviewSummations, + reviewSummations, + updated, + }; + }); +} + +/** + * Groups submissions by member. + * @param {Array} submissions All submissions. + * @return {Array} Grouped submissions by member. + */ +export function groupSubmissionsByMember(submissions) { if (!Array.isArray(submissions)) { return []; } @@ -55,7 +315,7 @@ function groupSubmissionsByMember(submissions) { const memberMap = new Map(); submissions.forEach((submission) => { - const memberHandle = _.get(submission, 'registrant.memberHandle', ''); + const memberHandle = getSubmissionHandle(submission); if (!memberHandle) { return; } @@ -104,13 +364,19 @@ class SubmissionsComponent extends React.Component { provisionalRankClicked: false, provisionalScoreClicked: false, downloadingAll: false, + completeSubmissions: null, + completeSubmissionsChallengeId: '', + loadingCompleteSubmissionsForChallengeId: '', }; + this.completeSubmissionsRequestId = 0; + this.unmounted = false; this.onHandleInformationPopup = this.onHandleInformationPopup.bind(this); this.getSubmissionsSortParam = this.getSubmissionsSortParam.bind(this); this.getFlagFirstTry = this.getFlagFirstTry.bind(this); this.updateSortedSubmissions = this.updateSortedSubmissions.bind(this); this.sortSubmissions = this.sortSubmissions.bind(this); this.shouldShowFinalMmResults = this.shouldShowFinalMmResults.bind(this); + this.loadCompleteSubmissions = this.loadCompleteSubmissions.bind(this); } componentDidMount() { @@ -125,23 +391,39 @@ class SubmissionsComponent extends React.Component { if (isMM) { loadMMSubmissions(challenge.id, auth.tokenV3); + } else { + this.loadCompleteSubmissions(); } this.updateSortedSubmissions(); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { const isMM = this.isMM(); const { submissions, mmSubmissions, submissionsSort } = this.props; + const { completeSubmissions } = this.state; + const challengeId = _.toString(_.get(this.props, 'challenge.id', '')); + const prevChallengeId = _.toString(_.get(prevProps, 'challenge.id', '')); + const tokenV3 = _.get(this.props, 'auth.tokenV3'); + const prevTokenV3 = _.get(prevProps, 'auth.tokenV3'); + if (!isMM && challengeId && (challengeId !== prevChallengeId || tokenV3 !== prevTokenV3)) { + this.loadCompleteSubmissions(); + } + if ( (!isMM && !_.isEqual(prevProps.submissions, submissions)) || (isMM && !_.isEqual(prevProps.mmSubmissions, mmSubmissions)) || !_.isEqual(prevProps.submissionsSort, submissionsSort) + || prevState.completeSubmissions !== completeSubmissions ) { this.updateSortedSubmissions(); } } + componentWillUnmount() { + this.unmounted = true; + } + onHandleInformationPopup(status, submissionId = null, member = '') { const { loadSubmissionInformation, auth, challenge } = this.props; this.setState({ @@ -235,13 +517,94 @@ class SubmissionsComponent extends React.Component { }; } + /** + * Returns the submissions source currently used by the tab. + * @return {Array} Complete loaded submissions when available, otherwise props. + */ + getSubmissionsSource() { + const { submissions, challenge } = this.props; + const { + completeSubmissions, + completeSubmissionsChallengeId, + } = this.state; + const challengeId = _.toString(_.get(challenge, 'id', '')); + + if ( + this.shouldLoadCompleteSubmissions() + && Array.isArray(completeSubmissions) + && completeSubmissionsChallengeId === challengeId + ) { + return completeSubmissions; + } + + return submissions; + } + + /** + * Returns whether the component should load a complete v6 submissions list. + * @return {Boolean} True when the tab needs client-side pagination fill-in. + */ + shouldLoadCompleteSubmissions() { + const { challenge } = this.props; + const trackName = getTrackName(_.get(challenge, 'track')); + return !this.isMM() && _.toLower(trackName || '') !== 'design'; + } + + /** + * Loads every v6 submissions page for non-MM challenge detail submissions. + * @return {Promise|undefined} Resolves after the complete list is stored. + */ + loadCompleteSubmissions() { + if (!this.shouldLoadCompleteSubmissions()) { + return undefined; + } + + const { auth, challenge, submissions } = this.props; + const challengeId = _.toString(_.get(challenge, 'id', '')); + if (!challengeId) { + return undefined; + } + + const requestId = this.completeSubmissionsRequestId + 1; + this.completeSubmissionsRequestId = requestId; + this.setState({ + loadingCompleteSubmissionsForChallengeId: challengeId, + }); + + return getChallengeSubmissionsService(_.get(auth, 'tokenV3'), challengeId) + .then(({ data }) => { + if (this.unmounted || requestId !== this.completeSubmissionsRequestId) { + return; + } + + const completeSubmissions = enrichChallengeSubmissions( + Array.isArray(data) ? data : [], + challenge, + submissions, + ); + this.setState({ + completeSubmissions, + completeSubmissionsChallengeId: challengeId, + loadingCompleteSubmissionsForChallengeId: '', + }); + }) + .catch(() => { + if (this.unmounted || requestId !== this.completeSubmissionsRequestId) { + return; + } + this.setState({ + loadingCompleteSubmissionsForChallengeId: '', + }); + }); + } + /** * Update sorted submission array */ updateSortedSubmissions() { const isMM = this.isMM(); - const { submissions, mmSubmissions } = this.props; - const source = isMM ? mmSubmissions : submissions; + const { mmSubmissions } = this.props; + const source = isMM ? mmSubmissions : this.getSubmissionsSource(); const sourceList = Array.isArray(source) ? source : []; let sortedSubmissions = _.cloneDeep(sourceList); @@ -477,6 +840,8 @@ class SubmissionsComponent extends React.Component { provisionalRankClicked, provisionalScoreClicked, downloadingAll, + completeSubmissions, + loadingCompleteSubmissionsForChallengeId, } = this.state; const sortOptionClicked = { @@ -599,7 +964,11 @@ class SubmissionsComponent extends React.Component { ); } - if (!_.isEmpty(loadingMMSubmissionsForChallengeId)) { + const loadingCompleteSubmissions = this.shouldLoadCompleteSubmissions() + && loadingCompleteSubmissionsForChallengeId === _.toString(challengeId) + && !Array.isArray(completeSubmissions); + + if (!_.isEmpty(loadingMMSubmissionsForChallengeId) || loadingCompleteSubmissions) { return
; } diff --git a/src/shared/components/challenge-detail/icons/clock.svg b/src/shared/components/challenge-detail/icons/clock.svg new file mode 100644 index 0000000000..1392205ea5 --- /dev/null +++ b/src/shared/components/challenge-detail/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index eaf2fa57a7..e5e0669273 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -454,6 +454,7 @@ class ChallengeDetailPageContainer extends React.Component { communitiesList, isLoadingChallenge, isLoadingTerms, + fetchChallengeFailure, onSelectorClicked, registerForChallenge, registering, @@ -553,7 +554,7 @@ class ChallengeDetailPageContainer extends React.Component { const results2 = resultsLoadedForChallengeId === _.toString(challengeId) ? results : null; - const isEmpty = _.isEmpty(challenge); + const isEmpty = fetchChallengeFailure || _.isEmpty(challenge); const isMM = checkIsMM(challenge); const isRDM = checkIsRDM(challenge); const isLegacyMM = isMM && Boolean(challenge.roundId); @@ -847,6 +848,7 @@ ChallengeDetailPageContainer.defaultProps = { communityId: null, isLoadingChallenge: false, isLoadingTerms: false, + fetchChallengeFailure: false, // loadingCheckpointResults: false, results: null, terms: [], @@ -884,6 +886,7 @@ ChallengeDetailPageContainer.propTypes = { getTypes: PT.func.isRequired, isLoadingChallenge: PT.bool, isLoadingTerms: PT.bool, + fetchChallengeFailure: PT.bool, loadChallengeDetails: PT.func.isRequired, fetchChallengeStatistics: PT.func.isRequired, getAllCountries: PT.func.isRequired, @@ -1468,6 +1471,7 @@ function mapStateToProps(state, props) { communitiesList: state.tcCommunities.list, domain: state.domain, isLoadingChallenge: Boolean(state.challenge.loadingDetailsForChallengeId), + fetchChallengeFailure: Boolean(state.challenge.fetchChallengeFailure), isLoadingTerms: _.isEqual(state.terms.loadingTermsForEntity, { type: 'challenge', id: challengeId, diff --git a/src/shared/services/reviewSummations.js b/src/shared/services/reviewSummations.js index 0ab16fa82b..9de5a7c2d1 100644 --- a/src/shared/services/reviewSummations.js +++ b/src/shared/services/reviewSummations.js @@ -12,7 +12,7 @@ async function fetchReviewSummationsPage({ aggregated, meta, }) { - const url = `${v6ApiUrl}${baseUrl}?challengeId=${encodeURIComponent(challengeId)}&perPage=${DEFAULT_PER_PAGE}&page=${page}`; + const url = `${v6ApiUrl}${baseUrl}?challengeId=${encodeURIComponent(challengeId)}&perPage=${DEFAULT_PER_PAGE}&page=${page}&metadata=true`; const response = await fetch(url, { method: 'GET', headers,