diff --git a/.circleci/config.yml b/.circleci/config.yml index 3614f76cc9..38f4423056 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,10 +28,11 @@ install_dependency: &install_dependency install_deploysuite: &install_deploysuite name: Installation of install_deploysuite. command: | - git clone --branch v1.4.14 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript + git clone --branch v1.4.17 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript cp ./../buildscript/master_deploy.sh . cp ./../buildscript/buildenv.sh . cp ./../buildscript/awsconfiguration.sh . + cp ./../buildscript/psvar-processor.sh . restore_cache_settings_for_build: &restore_cache_settings_for_build key: docker-node-modules-v4-{{ checksum "package-lock.json" }} @@ -43,247 +44,69 @@ save_cache_settings: &save_cache_settings build_docker_image: &build_docker_image name: Build of Docker image command: | - source buildenvvar + source buildvar_env ./build.sh no_output_timeout: 20m -jobs: - # Build & Deploy against development backend - "build-dev": - <<: *defaults - steps: + +build_steps: &build_steps # Initialization. - checkout - setup_remote_docker - run: *install_dependency - run: *install_deploysuite # Restoration of node_modules from cache. - - restore_cache: *restore_cache_settings_for_build + # - restore_cache: *restore_cache_settings_for_build # Build of Docker image. - run: name: "configuring environment" command: | - ./awsconfiguration.sh DEV - ./buildenv.sh -e DEV -b dev_communityapp_buildvar,dev_communityapp_deployvar -l dev_communityapp_buildvar_ps + ./awsconfiguration.sh ${DEPLOY_ENV} + source awsenvconf + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/buildvar + source buildvar_env + # ./buildenv.sh -e ${DEPLOY_ENV} -b dev_communityapp_buildvar,dev_communityapp_deployvar -l dev_communityapp_buildvar_ps - run: *build_docker_image # Caching node modules. - - save_cache: *save_cache_settings + # - save_cache: *save_cache_settings # Deployment. - deploy: name: Running MasterScript command: | source awsenvconf - source buildenvvar - ./master_deploy.sh -d ECS -e DEV -t latest -s dev_communityapp_taskvar -i communityapp -p FARGATE - - # Build & Deploy against testing backend - # "build-test": - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh DEV - # ./buildenv.sh -e DEV -b test_communityapp_buildvar,test_communityapp_deployvar -l dev_communityapp_buildvar_ps - # # Build of Docker image. - # - run: *build_docker_image - # # Caching node modules. - # - save_cache: *save_cache_settings - # # Deployment. - # - deploy: - # name: Running MasterScript - # command: | - # source awsenvconf - # source buildenvvar - # ./master_deploy.sh -d ECS -e DEV -t latest -s test_communityapp_taskvar -i communityapp - - # Build & Deploy against testing backend - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-250 - # "build-qa": - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh QA - # ./buildenv.sh -e QA -b qa_communityapp_buildvar,qa_communityapp_deployvar -l qa_communityapp_buildvar_ps - # # Build of Docker image. - # - run: *build_docker_image - # # Caching node modules. - # - save_cache: *save_cache_settings - # # Deployment. - # - deploy: - # name: Running MasterScript - # command: | - # source awsenvconf - # source buildenvvar - # ./master_deploy.sh -d ECS -e QA -t latest -s qa_communityapp_taskvar -i communityapp + # source buildenvvar + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/deployvar + source deployvar_env + ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -j /config/${APPNAME}/appvar -i ${APPNAME} -p FARGATE + # ./master_deploy.sh -d ECS -e DEV -t latest -s dev_communityapp_taskvar -i communityapp -p FARGATE + if [ "${DEPLOY_ENV}" = "PROD" ]; + then + # Executing plan + curl --request POST \ + --url https://circleci.com/api/v2/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pipeline \ + --header "Circle-Token: ${CIRCLE_TOKEN}" \ + --header 'content-type: application/json' \ + --data '{"branch":"'"$CIRCLE_BRANCH"'","parameters":{"run_smoketesting":true , "run_performancetesting":false, "run_basedeployment": false}}' + fi - # Build & Deploy against prod api backend - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-201 - # "build-prod-beta": - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh PROD - # ./buildenv.sh -e PROD -b beta_communityapp_buildvar,beta_communityapp_deployvar -l prod_communityapp_buildvar_ps - # # Build of Docker image. - # - run: *build_docker_image - # # Caching node modules. - # - save_cache: *save_cache_settings - # # Deployment. - # - deploy: - # name: Running MasterScript - # command: | - # source awsenvconf - # source buildenvvar - # ./master_deploy.sh -d ECS -e PROD -t latest -s beta_communityapp_taskvar, -i communityapp - - # Build & Deploy against prod api backend - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-202 - # "build-prod-staging": - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh PROD - # ./buildenv.sh -e PROD -b staging_communityapp_buildvar,staging_communityapp_deployvar -l prod_communityapp_buildvar_ps - # # Build of Docker image. - # - run: *build_docker_image - # # Caching node modules. - # - save_cache: *save_cache_settings - # # Deployment. - # - deploy: - # name: Running MasterScript - # command: | - # source awsenvconf - # source buildenvvar - # ./master_deploy.sh -d ECS -e PROD -t latest -s staging_communityapp_taskvar, -i communityapp - # curl --request POST \ - # --url https://circleci.com/api/v2/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pipeline \ - # --header "Circle-Token: ${CIRCLE_TOKEN}" \ - # --header 'content-type: application/json' \ - # --data '{"branch":"'"$CIRCLE_BRANCH"'","parameters":{"run_smoketesting":true , "run_performancetesting":false, "run_basedeployment": false}}' +jobs: + # Build & Deploy against development backend + "build-dev": + <<: *defaults + environment: + DEPLOY_ENV: "DEV" + LOGICAL_ENV: "dev" + APPNAME: "community-app" + steps: *build_steps # Build & Deploy against production backend "build-prod": <<: *defaults - steps: - # Initialization. - - checkout - - setup_remote_docker - - run: *install_dependency - - run: *install_deploysuite - # Restoration of node_modules from cache. - - restore_cache: *restore_cache_settings_for_build - - run: - name: "configuring environment" - command: | - ./awsconfiguration.sh PROD - ./buildenv.sh -e PROD -b prod_communityapp_buildvar,prod_communityapp_deployvar -l prod_communityapp_buildvar_ps - # Build of Docker image. - - run: *build_docker_image - # Caching node modules. - - save_cache: *save_cache_settings - # Deployment. - - deploy: - name: Running MasterScript - command: | - source awsenvconf - source buildenvvar - ./master_deploy.sh -d ECS -e PROD -t latest -s prod_communityapp_taskvar -i communityapp -p FARGATE - curl --request POST \ - --url https://circleci.com/api/v2/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pipeline \ - --header "Circle-Token: ${CIRCLE_TOKEN}" \ - --header 'content-type: application/json' \ - --data '{"branch":"'"$CIRCLE_BRANCH"'","parameters":{"run_smoketesting":true , "run_performancetesting":false, "run_basedeployment": false}}' - - # Automated Smoke Testing against Staging - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-202 - # Smoke-Testing-On-Staging: - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh PROD - # ./buildenv.sh -e PROD -b staging_communityapp_buildvar,staging_communityapp_deployvar - # - run: - # name: "Run automation" - # no_output_timeout: 20m - # command: | - # source awsenvconf - # source buildenvvar - # ./automated-smoke-test/smoketest.sh automation-config-staging.json prod - # - store_artifacts: - # path: ./automated-smoke-test/test-results + environment: + DEPLOY_ENV: "PROD" + LOGICAL_ENV: "prod" + APPNAME: "community-app" + steps: *build_steps - # Automated Smoke Testing against Production - # Smoke-Testing-On-Production: - # <<: *defaults - # steps: - # # Initialization. - # - checkout - # - setup_remote_docker - # - run: *install_dependency - # - run: *install_deploysuite - # # Restoration of node_modules from cache. - # - restore_cache: *restore_cache_settings_for_build - # - run: - # name: "configuring environment" - # command: | - # ./awsconfiguration.sh PROD - # ./buildenv.sh -e PROD -b prod_communityapp_buildvar,prod_communityapp_deployvar - # - run: - # name: "Run automation" - # no_output_timeout: 20m - # command: | - # source awsenvconf - # source buildenvvar - # ./automated-smoke-test/smoketest.sh automation-config-prod.json prod - # - store_artifacts: - # path: ./automated-smoke-test/test-results # Test job for the cases when we do not need deployment. It just rapidly # installs (updates) app dependencies, and runs tests (ESLint, Stylelint, @@ -358,51 +181,9 @@ workflows: branches: only: - develop - - pm-1346 - - pm-1358_1 - # This is alternate dev env for parallel testing - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-251 - # - "build-test": - # context : org-global - # filters: - # branches: - # only: - # - metadata-fix - # This is alternate dev env for parallel testing - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-250 - # - "build-qa": - # context : org-global - # filters: - # branches: - # only: - # - qaenv - # This is beta env for production soft releases - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-201 - # - "build-prod-beta": - # context : org-global - # filters: - # branches: - # only: - # - develop - # This is stage env for production QA releases - # Deprecate this workflow due to beta env shutdown - # https://topcoder.atlassian.net/browse/CORE-202 - # - "build-prod-staging": - # context : org-global - # filters: &filters-staging - # branches: - # only: - # - develop - # - CORE-201 - # Production builds are exectuted - # when PR is merged to the master - # Don't change anything in this configuration - # That might trigger wrong branch to be - # deployed on the production - # master branch. + - v6 + - PM-2479 + - "build-prod": context: org-global filters: &filters-prod @@ -417,6 +198,7 @@ workflows: ignore: - develop - submission_delete_button + - v6 Smoke Testing: when: << pipeline.parameters.run_smoketesting >> diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000000..7b9fa48394 --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,34 @@ +name: Trivy Scanner + +permissions: + contents: read + security-events: write +on: + push: + branches: + - main + - dev + pull_request: +jobs: + trivy-scan: + name: Use Trivy + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy scanner in repo mode + uses: aquasecurity/trivy-action@0.33.1 + with: + scan-type: "fs" + ignore-unfixed: true + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,UNKNOWN" + scanners: vuln,secret,misconfig,license + github-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: "trivy-results.sarif" diff --git a/.gitignore b/.gitignore index ea14c4e5a5..f522e77e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ logs *.log npm-debug.log* +# Local certs +*.pem + # Runtime data pids *.pid diff --git a/.huskyrc.json b/.huskyrc.json deleted file mode 100644 index 8fa9b4f133..0000000000 --- a/.huskyrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "hooks": { - "commit-msg": "npm run commitlint" - } -} diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..1effb00340 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +2.7 diff --git a/CODEOWNERS b/CODEOWNERS index 35b5ce982d..bbed3eaae8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @ajefts @luizrrodrigues +* @jmgasper @kkartunov diff --git a/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap b/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap index 9fe1f038fa..04faa6f2d0 100644 --- a/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap @@ -43,24 +43,7 @@ exports[`renders design 1`] = ` >
-
-
-

- Passed Screening -

-
-
-
-
+ />

@@ -132,40 +115,7 @@ exports[`renders develop 1`] = ` >

-
-
-

- 1 - - st - - - Place -

-
-

- 100% -

-

- Review Score -

-
-
-
-
+ />

diff --git a/__tests__/shared/components/SubmissionManagement/__snapshots__/SubmissionsTable.jsx.snap b/__tests__/shared/components/SubmissionManagement/__snapshots__/SubmissionsTable.jsx.snap index 40c84583d3..b9b5ff8970 100644 --- a/__tests__/shared/components/SubmissionManagement/__snapshots__/SubmissionsTable.jsx.snap +++ b/__tests__/shared/components/SubmissionManagement/__snapshots__/SubmissionsTable.jsx.snap @@ -16,11 +16,6 @@ exports[`Matches shallow shapshot 1`] = ` Submission Date - - Screening Status - @@ -79,11 +74,6 @@ exports[`Matches shallow shapshot 2`] = ` Submission Date - - Screening Status - diff --git a/build.sh b/build.sh index 9243995e2c..cb0b087bdc 100755 --- a/build.sh +++ b/build.sh @@ -5,7 +5,7 @@ set -eo pipefail # This script expects a single argument: NODE_CONFIG_ENV, which must be either # "development" or "production". # Builds Docker image of the app. -TAG="communityapp:latest" +TAG="community-app:latest" echo "COMMUNITY_APP_URL $COMMUNITY_APP_URL"; echo "NODE_ENV $NODE_CONFIG_ENV"; diff --git a/config/backup-default.js b/config/backup-default.js index 57d4eb617b..0b29ad84a6 100644 --- a/config/backup-default.js +++ b/config/backup-default.js @@ -8,6 +8,7 @@ module.exports = { V3: 'https://api.topcoder-dev.com/v3', V4: 'https://api.topcoder-dev.com/v4', V5: 'https://api.topcoder-dev.com/v5', + V6: 'https://api.topcoder-dev.com/v6', MM_BROKER: '/api', }, diff --git a/config/default.js b/config/default.js index 8f8f4d11a8..b4c990aa37 100644 --- a/config/default.js +++ b/config/default.js @@ -8,6 +8,7 @@ module.exports = { V3: 'https://api.topcoder-dev.com/v3', V4: 'https://api.topcoder-dev.com/v4', V5: 'https://api.topcoder-dev.com/v5', + V6: 'https://api.topcoder-dev.com/v6', MM_BROKER: '/api', }, @@ -167,10 +168,11 @@ module.exports = { // If a logged in user is a member of any of these groups, when they land on // their profile page (members/:handle), they'll be redirected to the "userProfile" url SUBDOMAIN_PROFILE_CONFIG: [{ - groupId: '20000000', communityId: 'wipro', communityName: 'topgear', userProfile: 'https://topgear-app.wipro.com/user-details', + groupId: 'b7f7c0f8-8ee8-409e-9e5c-33404983b635', communityId: 'wipro', communityName: 'topgear', userProfile: 'https://topgear-app.wipro.com/user-details', }], TIMELINE_WALL_API: 'https://api.topcoder-dev.com/v5/timeline-wall', - REVIEW_API_URL: '/reviewTypes', + REVIEW_TYPES_API_URL: '/reviewTypes', + REVIEW_SUMMATIONS_API_URL: '/reviewSummations', }, /* Information about Topcoder user groups can be cached in various places. diff --git a/config/production.js b/config/production.js index afebd77d64..c37c4fd63b 100644 --- a/config/production.js +++ b/config/production.js @@ -4,6 +4,7 @@ module.exports = { V3: 'https://api.topcoder.com/v3', V4: 'https://api.topcoder.com/v4', V5: 'https://api.topcoder.com/v5', + V6: 'https://api.topcoder.com/v6', }, AUTH0: { DOMAIN: 'topcoder.auth0.com', @@ -63,7 +64,8 @@ module.exports = { EMAIL_VERIFY_URL: 'http://www.topcoder.com/settings/account/changeEmail', THRIVE_FEED: 'https://topcoder.com/api/feeds/thrive', TIMELINE_WALL_API: 'https://api.topcoder.com/v5/timeline-wall', - REVIEW_API_URL: '/reviewTypes', + REVIEW_TYPES_API_URL: '/reviewTypes', + REVIEW_SUMMATIONS_API_URL: '/reviewSummations', }, /* Filestack configuration for uploading Submissions * These are for the production back end */ diff --git a/config/webpack/production.js b/config/webpack/production.js index 89010e8953..09f4fd1c0c 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -34,4 +34,7 @@ standardDevelopmentConfig.plugins.push(new webpack.DefinePlugin({ PUBLIC_PATH: JSON.stringify(publicPath), })); +// Ensure production bundles emit debuggable source maps that can be hosted alongside the assets +standardDevelopmentConfig.devtool = 'source-map'; + module.exports = webpackMerge.smart(standardDevelopmentConfig, defaultConfig); diff --git a/config/webpack/qa.js b/config/webpack/qa.js index b04db2dcaf..ced236a528 100644 --- a/config/webpack/qa.js +++ b/config/webpack/qa.js @@ -39,4 +39,7 @@ standardDevelopmentConfig.plugins.push(new webpack.DefinePlugin({ PUBLIC_PATH: JSON.stringify('/api/cdn/public/static-assets'), })); +// Align QA bundles with production by emitting source maps for debugging in browsers +standardDevelopmentConfig.devtool = 'source-map'; + module.exports = webpackMerge.smart(standardDevelopmentConfig, defaultConfig); diff --git a/package-lock.json b/package-lock.json index 10506105c5..be92a2ada7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1614,12 +1614,6 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, "@types/parse5": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", @@ -4836,12 +4830,6 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, - "ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true - }, "cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -5308,12 +5296,6 @@ "dot-prop": "^3.0.0" } }, - "compare-versions": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", - "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", - "dev": true - }, "component-classes": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/component-classes/-/component-classes-1.2.6.tgz", @@ -9718,15 +9700,6 @@ "locate-path": "^2.0.0" } }, - "find-versions": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-4.0.0.tgz", - "integrity": "sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ==", - "dev": true, - "requires": { - "semver-regex": "^3.1.2" - } - }, "find-with-regex": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/find-with-regex/-/find-with-regex-1.1.3.tgz", @@ -12046,164 +12019,6 @@ } } }, - "husky": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz", - "integrity": "sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "ci-info": "^2.0.0", - "compare-versions": "^3.6.0", - "cosmiconfig": "^7.0.0", - "find-versions": "^4.0.0", - "opencollective-postinstall": "^2.0.2", - "pkg-dir": "^5.0.0", - "please-upgrade-node": "^3.2.0", - "slash": "^3.0.0", - "which-pm-runs": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "pkg-dir": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dev": true, - "requires": { - "find-up": "^5.0.0" - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", @@ -17851,12 +17666,6 @@ "mimic-fn": "^1.0.0" } }, - "opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "dev": true - }, "opentracing": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.1.tgz", @@ -18325,15 +18134,6 @@ "find-up": "^2.1.0" } }, - "please-upgrade-node": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", - "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", - "dev": true, - "requires": { - "semver-compare": "^1.0.0" - } - }, "pluralize": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", @@ -23113,18 +22913,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, - "semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true - }, - "semver-regex": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.4.tgz", - "integrity": "sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA==", - "dev": true - }, "send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -25898,8 +25686,8 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, "topcoder-react-lib": { - "version": "git+https://github.com/topcoder-platform/topcoder-react-lib.git#7fb290a44c5ac6414b45588e0819dca81031db1b", - "from": "git+https://github.com/topcoder-platform/topcoder-react-lib.git#v1.2.21", + "version": "github:topcoder-platform/topcoder-react-lib#2a61cecad3917f084797af010a93f7f544840174", + "from": "github:topcoder-platform/topcoder-react-lib#v6", "requires": { "@topcoder-platform/tc-auth-lib": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", "auth0-js": "^6.8.4", @@ -25918,7 +25706,7 @@ "redux-actions": "^2.4.0", "tc-core-library-js": "github:appirio-tech/tc-core-library-js#v2.6", "to-capital-case": "^1.0.0", - "topcoder-react-utils": "0.7.5" + "topcoder-react-utils": "github:topcoder-platform/topcoder-react-utils#v6" }, "dependencies": { "@topcoder-platform/tc-auth-lib": { @@ -26119,11 +25907,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==" }, - "serialize-javascript": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.1.tgz", - "integrity": "sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A==" - }, "superagent": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", @@ -26179,77 +25962,6 @@ } } }, - "topcoder-react-utils": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/topcoder-react-utils/-/topcoder-react-utils-0.7.5.tgz", - "integrity": "sha512-/jolO/UUCC/FL/MniBMFi9d7Wc1KbzwvgT5STGs4T+7u7R26bQugGPpGVISEPuglsmW0Xybh6iRi+pT/muOkbg==", - "requires": { - "babel-runtime": "^6.26.0", - "body-parser": "^1.18.3", - "command-line-args": "^5.0.2", - "command-line-usage": "^5.0.5", - "compression": "^1.7.2", - "config": "^1.30.0", - "cookie-parser": "^1.4.3", - "express": "^4.16.3", - "helmet": "^3.12.1", - "lodash": "^4.17.10", - "moment": "^2.22.2", - "morgan": "^1.9.0", - "node-forge": "^0.7.5", - "prop-types": "^15.6.2", - "raf": "^3.4.0", - "react": "^16.4.1", - "react-css-super-themr": "^2.2.0", - "react-dom": "^16.4.1", - "react-helmet": "^5.2.0", - "react-redux": "^5.0.7", - "react-router-dom": "^4.3.1", - "redux": "^3.7.2", - "redux-actions": "^2.4.0", - "redux-devtools": "^3.4.1", - "redux-devtools-dock-monitor": "^1.1.3", - "redux-devtools-log-monitor": "^1.4.0", - "redux-promise": "^0.6.0", - "request-ip": "^2.0.2", - "serialize-javascript": "^1.5.0", - "serve-favicon": "^2.5.0", - "shortid": "^2.2.8", - "url-parse": "^1.4.1" - }, - "dependencies": { - "config": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/config/-/config-1.31.0.tgz", - "integrity": "sha512-Ep/l9Rd1J9IPueztJfpbOqVzuKHQh4ZODMNt9xqTYdBBNRXbV4oTu34kCkkfdRVcDq0ohtpaeXGgb+c0LQxFRA==", - "requires": { - "json5": "^1.0.1" - } - }, - "json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "requires": { - "minimist": "^1.2.0" - } - }, - "react-redux": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz", - "integrity": "sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q==", - "requires": { - "@babel/runtime": "^7.1.2", - "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4", - "loose-envify": "^1.1.0", - "prop-types": "^15.6.1", - "react-is": "^16.6.0", - "react-lifecycles-compat": "^3.0.0" - } - } - } - }, "winchan": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/winchan/-/winchan-0.1.4.tgz", @@ -26361,7 +26073,7 @@ }, "topcoder-react-utils": { "version": "github:topcoder-platform/topcoder-react-utils#0fcf9a756a371e0ad633636ba050a7881d862cb8", - "from": "github:topcoder-platform/topcoder-react-utils#v0.9.5", + "from": "github:topcoder-platform/topcoder-react-utils#v6", "requires": { "babel-runtime": "^6.26.0", "body-parser": "^1.18.3", @@ -28830,12 +28542,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true - }, "yargs": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", @@ -28931,12 +28637,6 @@ "decamelize": "^1.2.0" } }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/package.json b/package.json index dde68072cd..21a134470d 100644 --- a/package.json +++ b/package.json @@ -164,9 +164,9 @@ "supertest": "^3.1.0", "tc-core-library-js": "github:appirio-tech/tc-core-library-js#v2.6.3.1", "tc-ui": "^1.0.12", - "topcoder-react-lib": "git+https://github.com/topcoder-platform/topcoder-react-lib#v1.2.21", + "topcoder-react-lib": "github:topcoder-platform/topcoder-react-lib#v6", "topcoder-react-ui-kit": "2.0.1", - "topcoder-react-utils": "github:topcoder-platform/topcoder-react-utils#v0.9.5", + "topcoder-react-utils": "github:topcoder-platform/topcoder-react-utils#v6", "turndown": "^4.0.2", "uninav-react": "git+https://github.com/topcoder-platform/uninav-react.git#0.0.2", "url-parse": "^1.4.1", @@ -212,7 +212,6 @@ "eslint-plugin-react": "^7.32.2", "file-loader": "^1.1.11", "happypack": "^5.0.1", - "husky": "^4.2.1", "identity-obj-proxy": "^3.0.0", "jest": "^23.2.0", "jsdoc-to-markdown": "^4.0.1", diff --git a/src/server/index.js b/src/server/index.js index 7bb01dd9c2..795b51d485 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -69,7 +69,7 @@ const getTimestamp = async () => { timestamp = moment(tsData.timestamp).valueOf(); } catch (err) { - console.error('Error:', err.message); + logger.error('Error:', err.message); } return timestamp; @@ -211,6 +211,7 @@ async function onExpressJsSetup(server) { + ` ${config.API.V3}/` + ` ${config.API.V4}/` + ` ${config.API.V5}/` + + ` ${config.API.V6}/` + ` ${config.CDN.PUBLIC}` + ` ${config.URL.COMMUNITY_APP}` + ' https://api.segment.io' diff --git a/src/server/routes/contentful.js b/src/server/routes/contentful.js index 38c8249ec4..8189c525c5 100644 --- a/src/server/routes/contentful.js +++ b/src/server/routes/contentful.js @@ -29,7 +29,7 @@ routes.options('*', cors()); /* Gets non-image asset file. */ routes.use( '/:spaceName/:environment/assets/:id/:version/:name', - (req, res) => { + (req, res, next) => { try { const { environment, @@ -45,7 +45,7 @@ routes.use( const url = new URL(`https://${ASSETS_DOMAIN}/spaces/${spaceId}/environments/${environment}/${id}/${version}/${name}`); res.redirect(url.href); } catch (e) { - console.log('error in getting asset', e); + next(e); } }, ); @@ -53,7 +53,7 @@ routes.use( /* Gets image file. */ routes.use( '/:spaceName/:environment/images/:id/:version/:name', - (req, res) => { + (req, res, next) => { try { const { environment, @@ -69,7 +69,7 @@ routes.use( const url = new URL(`https://${IMAGES_DOMAIN}/spaces/${spaceId}/environments/${environment}/${id}/${version}/${name}`); res.redirect(url.href); } catch (e) { - console.log('error in getting image', e); + next(e); } }, ); diff --git a/src/server/services/mmLeaderboard.js b/src/server/services/mmLeaderboard.js index 286e3c14da..ff1209c808 100644 --- a/src/server/services/mmLeaderboard.js +++ b/src/server/services/mmLeaderboard.js @@ -5,7 +5,7 @@ import { services } from 'topcoder-react-lib'; import xss from 'xss'; -const { api, submissions } = services; +const { api } = services; /** * Auxiliary class that handles communication with MMLeaderboard @@ -20,14 +20,27 @@ export default class MMLService { try { const sanitizedId = xss(req.params.id); const m2mToken = await api.getTcM2mToken(); - const subSrv = submissions.getService(m2mToken); - const reviewIds = await subSrv.getScanReviewIds(); - const v5api = api.getApiV5(m2mToken); - const subs = await v5api.get(`/submissions?challengeId=${sanitizedId}&page=1&perPage=500`); + const v6api = api.getApi('V6', m2mToken); + const subsRes = await v6api.get(`/submissions?challengeId=${sanitizedId}&page=1&perPage=500`); + const json = await subsRes.json(); + const data = (json && json.data) ? json.data : []; + + // Map v6 reviewSummations into a "review" array compatible with existing client code + const mapped = data.map(s => ({ + ...s, + createdBy: s.submitterHandle || s.createdBy, + review: (s.reviewSummation || []).map(rs => ({ + score: rs.aggregateScore, + updatedAt: rs.updatedAt || rs.reviewedDate || s.updatedAt || s.submittedDate, + createdAt: rs.createdAt || rs.reviewedDate || s.createdAt || s.submittedDate, + createdBy: rs.createdBy, + })), + })); + return res.send({ id: sanitizedId, - subs: await subs.json(), - reviewIds, + subs: mapped, + reviewIds: [], }); } catch (err) { return next(err); diff --git a/src/server/tc-communities/wipro/metadata.json b/src/server/tc-communities/wipro/metadata.json index 4ba1897f95..a7dc0693fb 100644 --- a/src/server/tc-communities/wipro/metadata.json +++ b/src/server/tc-communities/wipro/metadata.json @@ -1,9 +1,7 @@ { - "authorizedGroupIds": [ - "20000000", "20000005", "20000011", "20000016" - ], + "authorizedGroupIds": [], "challengeFilter": { - "groupIds": ["20000000"] + "groupIds": ["b7f7c0f8-8ee8-409e-9e5c-33404983b635"] }, "challengeListing": { "ignoreCommunityFilterByDefault": true, @@ -11,18 +9,19 @@ }, "communityId": "wipro", "communityName": "TopGear Community", - "groupIds": ["20000000"], + "groupIds": ["b7f7c0f8-8ee8-409e-9e5c-33404983b635"], "leaderboardApiUrl": "https://api.topcoder.com/v4/looks/458/run/json/", - "logos": [{ - "img": "/community-app-assets/themes/wipro/wipro-logo.png", - "url": "http://www.wipro.com/" - }, { - "img": "/community-app-assets/themes/wipro/logo_topcoder_with_name.svg", - "url": "https://www.topcoder.com" - }], - "additionalLogos": [ - "/community-app-assets/themes/wipro/topgear_logo.png" + "logos": [ + { + "img": "/community-app-assets/themes/wipro/wipro-logo.png", + "url": "http://www.wipro.com/" + }, + { + "img": "/community-app-assets/themes/wipro/logo_topcoder_with_name.svg", + "url": "https://www.topcoder.com" + } ], + "additionalLogos": ["/community-app-assets/themes/wipro/topgear_logo.png"], "hideSearch": true, "chevronOverAvatar": true, "footerText": "© Copyright Wipro Ltd 2019", @@ -30,10 +29,12 @@ { "title": "Home", "url": "https://topgear-app.wipro.com" - }, { + }, + { "title": "Challenges", "url": "/challenges" - }, { + }, + { "title": "Leaderboard", "url": "/leaderboard" } @@ -41,7 +42,7 @@ "newsFeed": "http://www.topcoder.com/feed", "subdomains": ["topgear", "wipro"], "description": "Wipro's TopGear community.", - "image":"wipro.jpg", + "image": "wipro.jpg", "accessDeniedPage": { "viewportId": "2ymrKrU9DISEX0zjAYj2k7", "spaceName": "topgear" diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index 7d7784007c..b692edbd69 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -10,11 +10,11 @@ import { processSRM } from 'utils/tc'; import { errors, services } from 'topcoder-react-lib'; import { BUCKETS } from 'utils/challenge-listing/buckets'; import SORT from 'utils/challenge-listing/sort'; +import getReviewOpportunities from 'services/reviewOpportunities'; import getCopilotOpportunities from '../../services/copilotOpportunities'; const { fireErrorMessage } = errors; const { getService } = services.challenge; -const { getReviewOpportunitiesService } = services.reviewOpportunities; /** * The maximum number of challenges to fetch in a single API call. @@ -203,7 +203,7 @@ function getActiveChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Active', + status: 'ACTIVE', currentPhaseName: 'Submission', registrationEndDateEnd: new Date().toISOString(), perPage: PAGE_SIZE, @@ -266,7 +266,7 @@ function getOpenForRegistrationChallengesDone(uuid, page, backendFilter, frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Active', + status: 'ACTIVE', currentPhaseName: 'Registration', perPage: PAGE_SIZE, page: page + 1, @@ -302,7 +302,7 @@ function getMyChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter = { frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Active', + status: 'ACTIVE', memberId: userId, perPage: PAGE_SIZE, page: page + 1, @@ -328,7 +328,7 @@ function getAllChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter = frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Active', + status: 'ACTIVE', perPage: PAGE_SIZE, page: page + 1, sortBy: sortObj.field ? sortObj.field : sorts[BUCKETS.ALL], @@ -354,7 +354,7 @@ function getMyPastChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Completed', + status: 'COMPLETED', memberId: userId, perPage: PAGE_SIZE, page: page + 1, @@ -382,7 +382,7 @@ function getTotalChallengesCountDone(uuid, tokenV3, frontFilter = {}) { frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Active', + status: 'ACTIVE', isLightweight: true, perPage: 1, }, @@ -465,7 +465,7 @@ function getPastChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter = frontFilter: { ...frontFilter, ...extractSearchFilter(frontFilter), - status: 'Completed', + status: 'COMPLETED', perPage: PAGE_SIZE, page: page + 1, sortBy: sortObj.field ? sortObj.field : sorts[BUCKETS.ALL_PAST], @@ -489,9 +489,8 @@ function getPastChallengesDone(uuid, page, backendFilter, tokenV3, frontFilter = * @param {String} tokenV3 Optional. Topcoder auth token v3. * @return {Object} Action object */ -function getReviewOpportunitiesDone(uuid, page, tokenV3) { - return getReviewOpportunitiesService(tokenV3) - .getReviewOpportunities(REVIEW_OPPORTUNITY_PAGE_SIZE, page * REVIEW_OPPORTUNITY_PAGE_SIZE) +function getReviewOpportunitiesDone(uuid, page) { + return getReviewOpportunities(page, REVIEW_OPPORTUNITY_PAGE_SIZE) .then(loaded => ({ uuid, loaded })) .catch((error) => { fireErrorMessage('Error Getting Review Opportunities', error.content || error); diff --git a/src/shared/actions/contentful.js b/src/shared/actions/contentful.js index 28dd6c0f3e..986a2d2afe 100644 --- a/src/shared/actions/contentful.js +++ b/src/shared/actions/contentful.js @@ -342,7 +342,7 @@ async function getChallengesBlockDone(blockProps) { const challengesService = services.challenge.getService(); const filter = {}; if (!block.fields.completedChallenges) { - filter.status = 'Active'; + filter.status = 'ACTIVE'; } if (block.fields.challengeTitleContains) { filter.name = block.fields.challengeTitleContains; diff --git a/src/shared/actions/mmLeaderboard.js b/src/shared/actions/mmLeaderboard.js index 956671d831..0604f4e7a9 100644 --- a/src/shared/actions/mmLeaderboard.js +++ b/src/shared/actions/mmLeaderboard.js @@ -26,11 +26,25 @@ async function getMMLeaderboardDone(id) { sub.review = _.filter(sub.review, r => res.reviewIds.indexOf(r.typeId) === -1); return sub; }); - const sortedSubs = _.orderBy(filteredSubs, ['updated'], ['desc']); - const scores = _.orderBy(_.compact(sortedSubs[0].review), ['updated'], ['desc']); + // New API uses updatedAt/createdAt; fallback to older fields where needed + const sortedSubs = _.orderBy( + filteredSubs, + [s => s.updatedAt || s.updated || s.createdAt || s.submittedDate || ''], + ['desc'], + ); + const scores = _.orderBy( + _.compact(sortedSubs[0].review), + [r => r.updatedAt || r.updated || r.createdAt || ''], + ['desc'], + ); + const latestDate = sortedSubs[0].submittedDate + || sortedSubs[0].createdAt + || sortedSubs[0].updatedAt + || sortedSubs[0].created + || ''; data.push({ createdBy: handle, - updated: sortedSubs[0].submittedDate, + updated: latestDate, id: sortedSubs[0].id, score: scores && scores.length ? scores[0].score : '...', }); @@ -42,7 +56,7 @@ async function getMMLeaderboardDone(id) { })); // Fetch member photos and rating for top 10 const results = await Promise.all( - _.take(data, 10).map(d => fetch(`${config.API.V5}/members/${d.createdBy}`)), + _.take(data, 10).map(d => fetch(`${config.API.V6}/members/${d.createdBy}`)), ); const memberData = await Promise.all(results.map(r => r.json())); // merge with data diff --git a/src/shared/actions/page/profile.js b/src/shared/actions/page/profile.js index 60e15d117b..57ff1e54a2 100644 --- a/src/shared/actions/page/profile.js +++ b/src/shared/actions/page/profile.js @@ -23,9 +23,9 @@ async function getGamificationBadgesInit(handle) { */ async function getGamificationBadgesDone(handle, limit) { try { - const memberInfo = await fetch(`${config.API.V5}/members/${handle}`) + const memberInfo = await fetch(`${config.API.V6}/members/${handle}`) .then(response => response.json()); - const badges = await fetch(`${config.API.V5}/gamification/badges/assigned/${memberInfo.userId}?organization_id=${config.GAMIFICATION.ORG_ID}&limit=${limit || 4}`) + const badges = await fetch(`${config.API.V6}/gamification/badges/assigned/${memberInfo.userId}?organization_id=${config.GAMIFICATION.ORG_ID}&limit=${limit || 4}`) .then(response => response.json()); return { diff --git a/src/shared/actions/page/review-opportunity-details.js b/src/shared/actions/page/review-opportunity-details.js index 6b9c75ffa5..f21f4769a0 100644 --- a/src/shared/actions/page/review-opportunity-details.js +++ b/src/shared/actions/page/review-opportunity-details.js @@ -3,6 +3,10 @@ */ import _ from 'lodash'; import { createActions } from 'redux-actions'; +import { logger } from 'topcoder-react-lib'; + +import { getDetails, submitApplications } from '../../services/reviewOpportunities'; + /* Holds valid values for the tab state. */ export const TABS = { @@ -10,9 +14,60 @@ export const TABS = { CHALLENGE_SPEC: 'CHALLENGE_SPEC', }; +/** + * @static + * @desc Creates an action that signals beginning of loading the review + * opportunity details. + * @return {Action} + */ +function getDetailsInit() {} + +/** + * @static + * @desc Creates an action that gets details of a review opportunity for + * the specified challenge. + * @param {Number} challengeId The ID of the challenge (not the opportunity id) + * @param {Number} opportunityId The ID of the review opportunity + * @param {String} tokenV3=null Optional. Topcoder auth token v3. + * @return {Action} + */ +function getDetailsDone(challengeId, opportunityId) { + return getDetails(challengeId, opportunityId) + .then(details => ({ details })) + .catch((error) => { + if (error.status !== 401) { + logger.error('Error Getting Review Opportunity Details', error.content || error); + } + return Promise.reject(error.status); + }); +} + +/** + * @static + * @desc Creates an action that signals beginning of review application process. + * @return {Action} + */ +function submitAppliationInit() {} + +/** + * @static + * @desc Creates an action that submits application for a review opportunity. + * @param {Number} challengeId The ID of the challenge (not the opportunity id) + * @param {Array} roleIds Array of roleId Numbers to cancel applications for + * @param {String} tokenV3 Required. Topcoder auth token v3. + * @return {Action} + */ +function submitApplicationsDone(challengeId, tokenV3) { + return submitApplications(challengeId, tokenV3); +} + export default createActions({ PAGE: { REVIEW_OPPORTUNITY_DETAILS: { + GET_DETAILS_INIT: getDetailsInit, + GET_DETAILS_DONE: getDetailsDone, + SUBMIT_APPLICATIONS_INIT: submitAppliationInit, + SUBMIT_APPLICATIONS_DONE: submitApplicationsDone, SELECT_TAB: _.identity, SET_ROLES: _.identity, TOGGLE_APPLY_MODAL: _.identity, diff --git a/src/shared/actions/page/submission/index.js b/src/shared/actions/page/submission/index.js index bcc0046b98..85e7e6beb4 100644 --- a/src/shared/actions/page/submission/index.js +++ b/src/shared/actions/page/submission/index.js @@ -24,7 +24,7 @@ const Api = services.api.default; * @return Promise */ function submitDone(tokenV3, tokenV2, submissionId, body, track, progress) { - const api = new Api(config.API.V5, tokenV3); + const api = new Api(config.API.V6, tokenV3); const url = '/submissions/'; return api.upload(url, { body, diff --git a/src/shared/components/ChallengeTile/index.jsx b/src/shared/components/ChallengeTile/index.jsx index 0dbc7b7a22..0f0b47234d 100644 --- a/src/shared/components/ChallengeTile/index.jsx +++ b/src/shared/components/ChallengeTile/index.jsx @@ -7,7 +7,7 @@ import React from 'react'; import PT from 'prop-types'; import { Link } from 'react-router-dom'; import { formatDate, COMPETITION_TRACKS } from 'utils/tc'; -import { isMM } from 'utils/challenge'; +import { isMM, getTrackName, getTypeName } from 'utils/challenge'; import ReactImageFallback from 'react-image-fallback'; import InviteOnly from 'assets/images/ico-invite-only-prj.svg'; import WinnerRibbon from 'assets/images/ico-winner-ribbon.svg'; @@ -75,9 +75,11 @@ class ChallengeTile extends React.Component { } = this.props; const { track, type } = challenge; + const trackName = getTrackName(track); + const typeName = getTypeName(type); const roles = _.get(challenge, 'userDetails.roles'); - const outStyleName = `challenge tile-view ${track.replace(' ', '-').toLowerCase()}`; + const outStyleName = `challenge tile-view ${(trackName || '').replace(' ', '-').toLowerCase()}`; const extraStyle = { width: '285px', padding: '15px', @@ -107,7 +109,7 @@ class ChallengeTile extends React.Component { )}

- {underscoreReplace(type)} + {underscoreReplace(typeName)}

{challenge.submissionEndDate && formatDate(challenge.submissionEndDate)} diff --git a/src/shared/components/Contentful/ChallengesBlock/Card/index.jsx b/src/shared/components/Contentful/ChallengesBlock/Card/index.jsx index 565036305b..27254b2a30 100644 --- a/src/shared/components/Contentful/ChallengesBlock/Card/index.jsx +++ b/src/shared/components/Contentful/ChallengesBlock/Card/index.jsx @@ -13,6 +13,7 @@ import { import { Link } from 'topcoder-react-utils'; import { COMPETITION_TRACKS } from 'utils/tc'; +import { getTrackName } from 'utils/challenge'; import './style.scss'; @@ -29,7 +30,7 @@ export default function Card({ } = challenge; let TrackTag; - switch (track.toLowerCase()) { + switch ((getTrackName(track) || '').toLowerCase()) { case COMPETITION_TRACKS.DS: TrackTag = DataScienceTrackTag; break; diff --git a/src/shared/components/Contentful/Tabs/Tabs.jsx b/src/shared/components/Contentful/Tabs/Tabs.jsx index 4eb0541cdf..a21609964a 100644 --- a/src/shared/components/Contentful/Tabs/Tabs.jsx +++ b/src/shared/components/Contentful/Tabs/Tabs.jsx @@ -99,130 +99,155 @@ export default class TabsItemsLoader extends Component { preview={preview} spaceName={spaceName} environment={environment} - render={data => ( - this.setState({ tabIndex: tIndx, mobileTabsShow: false })} - forceRenderTabPanel={forceRenderTabPanel} - > -

- { - themeName === 'Underline box' ? ( - - ) : null - } - - { - _.map(data.entries.items, tabItem => ( - - - - )) + + + ) : null } - -
- { - _.map(data.entries.items, tabItem => ( - - { - tabItem.fields.panelDescription ? ( -
- -
- ) : null - } + { - _.map(tabItem.fields.panel, panelItemLink => ( - { - const { id } = panelItemLink.sys; - const entryType = panelItem.entries.items[id].sys.contentType.sys.id; - if (entryType === 'appComponent') { - return AppComponentSwitch(panelItem.entries.items[id]); - } - if (entryType === 'contentBlock') { - return ( - - ); - } - if (entryType === 'tabs') { - const { fields } = panelItem.entries.items[id]; - return ( - - ); - } - - if (entryType === 'viewport') { - return ( - - ); - } - return null; - }} + _.map(tabItems, tabItem => ( + + > + + )) } -
- )) - } - - )} + +
+ { + _.map(tabItems, tabItem => ( + + { + tabItem.fields.panelDescription ? ( +
+ +
+ ) : null + } + { + _.map(tabItem.fields.panel, panelItemLink => ( + { + const { id } = panelItemLink.sys; + const entryType = panelItem.entries.items[id].sys.contentType.sys.id; + if (entryType === 'appComponent') { + return AppComponentSwitch(panelItem.entries.items[id]); + } + if (entryType === 'contentBlock') { + return ( + + ); + } + if (entryType === 'tabs') { + const { fields } = panelItem.entries.items[id]; + return ( + + ); + } + + if (entryType === 'viewport') { + return ( + + ); + } + return null; + }} + key={tabItem.sys.id} + /> + )) + } +
+ )) + } + + ); + }} renderPlaceholder={LoadingIndicator} /> ); diff --git a/src/shared/components/Dashboard/Challenges/index.jsx b/src/shared/components/Dashboard/Challenges/index.jsx index bbc8d9d009..b9ae246311 100644 --- a/src/shared/components/Dashboard/Challenges/index.jsx +++ b/src/shared/components/Dashboard/Challenges/index.jsx @@ -51,7 +51,7 @@ export default function ChallengesFeed({ {`$${_.sum( challenge.prizeSets - .filter(set => set.type === 'placement') + .filter(set => set.type === 'PLACEMENT') .map(item => _.sum(item.prizes.map(prize => prize.value))), ).toLocaleString()}`} diff --git a/src/shared/components/Looker/index.jsx b/src/shared/components/Looker/index.jsx index 773e7f6cfb..9aac606e27 100644 --- a/src/shared/components/Looker/index.jsx +++ b/src/shared/components/Looker/index.jsx @@ -26,6 +26,36 @@ import cn from 'classnames'; import { Scrollbars } from 'react-custom-scrollbars'; import './style.scss'; +// Format strings that represent numeric values (optionally with a leading +// '$' and/or a leading '-') by inserting thousands separators while +// preserving any decimal part. Non-numeric strings are returned as-is. +function formatMaybeNumericString(s) { + if (typeof s !== 'string') return s; + const str = s.trim(); + if (!str) return s; + if (str.includes(',')) return s; // assume already formatted + + let prefix = ''; + let sign = ''; + let numberPart = str; + + if (numberPart[0] === '$') { + prefix = '$'; + numberPart = numberPart.slice(1); + } + if (numberPart[0] === '-') { + sign = '-'; + numberPart = numberPart.slice(1); + } + + if (!/^\d+(\.\d+)?$/.test(numberPart)) return s; + + const [intPart, decPart] = numberPart.split('.'); + const intWithCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const dec = decPart ? `.${decPart}` : ''; + return `${prefix}${sign}${intWithCommas}${dec}`; +} + export default class Looker extends Component { constructor(props) { super(props); @@ -77,7 +107,7 @@ export default class Looker extends Component { if (property) { if (lookerData.length > 0 && lookerData[0][property]) { if (typeof lookerData[0][property] === 'string') { - return lookerData[0][property]; + return formatMaybeNumericString(lookerData[0][property]); } if (typeof lookerData[0][property] === 'number') { return lookerData[0][property].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); @@ -158,16 +188,25 @@ export default class Looker extends Component { } const { styles } = c; let value = ''; + let cellKey; if (limit <= 0 || i < limit) { - if (typeof record[prop] === 'string') { - value = record[prop]; - } - if (typeof record[prop] === 'number') { + // Special-case a column with property "rank" to display + // the current row index (1-based). This mirrors the old + // Looker behavior and ensures alignment even when the + // underlying data does not include a rank field. + if ((prop || '').toString().toLowerCase() === 'rank') { + value = i + 1; + cellKey = `${prop}-index-${i}`; + } else if (typeof record[prop] === 'string') { + value = formatMaybeNumericString(record[prop]); + cellKey = record[prop]; + } else if (typeof record[prop] === 'number') { value = record[prop].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + cellKey = record[prop]; } } return value ? ( - + {memberLinks ? ( {value} @@ -210,7 +249,7 @@ export default class Looker extends Component { try { const retValue = f(lookerData); if (typeof retValue === 'string') { - return retValue; + return formatMaybeNumericString(retValue); } if (typeof retValue === 'number') { return retValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); diff --git a/src/shared/components/ProfileBadgesPage/index.jsx b/src/shared/components/ProfileBadgesPage/index.jsx index fd69254b3a..e1015a16d0 100644 --- a/src/shared/components/ProfileBadgesPage/index.jsx +++ b/src/shared/components/ProfileBadgesPage/index.jsx @@ -76,7 +76,7 @@ const ProfileBadges = ({ badges, handleParam }) => { }
-
+
{title}
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/ApplicationsTab/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/ApplicationsTab/index.jsx index c57b860022..c45d2d9cbc 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/ApplicationsTab/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/ApplicationsTab/index.jsx @@ -30,7 +30,7 @@ const ApplicationsTab = ({ applications }) => (
{ applications - && applications.filter(app => app.status !== 'Cancelled').map(app => ( + && applications.filter(app => app.status !== 'CANCELLED').map(app => (
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/ApplyModal/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/ApplyModal/index.jsx index c09fa5159e..f1d67a269c 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/ApplyModal/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/ApplyModal/index.jsx @@ -1,7 +1,6 @@ /** * Modal that displays a list of available reviewer roles and allows user to select and apply */ -import _ from 'lodash'; import React from 'react'; import PT from 'prop-types'; import { Modal, PrimaryButton, Button } from 'topcoder-react-ui-kit'; @@ -26,23 +25,29 @@ class ApplyModal extends React.Component { render() { const { - details, handle, onApply, onCancel, toggleRole, selectedRoles, + details, handle, onApply, onCancel, selectedRoles, } = this.props; const positions = openPositionsByRole(details); + const position = positions[0]; const previousRoles = activeRoleIds(details, handle); const hasApplied = Boolean(previousRoles.length); - const hasChanged = !_.isEqual(new Set(selectedRoles), new Set(previousRoles)); + + const onHandleApply = () => { + const updatedRoles = [...selectedRoles]; + if (position.role === 'Reviewer') { + updatedRoles.push(position.roleId); + } + onApply(updatedRoles); + }; return (

- {hasApplied ? 'Manage Applications' : 'Apply Reviewer Positions'} + {hasApplied ? 'Reviewer Application' : 'Apply Reviewer Position'}

- Select the review roles you would like to apply for and click the button. - The system will assign members that best meet the review requirements for this contest. - Although you will be assigned to at most one review position, - applying for multiple roles increases your chances of being selected. + Click Apply Now to apply as a Reviewer for this challenge. + Payment and positions are shown below.

@@ -54,44 +59,23 @@ class ApplyModal extends React.Component {
Payment
-
- { - positions.map(position => ( -
-
- - {position.role} - -
-
- {position.openPositions} -
-
- $ - {position.payment} - .00* -
-
-
- toggleRole(position.roleId)} - type="checkbox" - /> - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} -
-
- )) - } +
+
+ + {position.role} + +
+
+ {position.openPositions} +
+
+ $ + {position.payment} + .00* +
+

*Depends on the number of submissions, the actual payment may differ. @@ -100,12 +84,17 @@ class ApplyModal extends React.Component { + {!hasApplied + && ( - {hasApplied ? 'Update' : 'Apply Now'} + Apply Now + ) + } +

); @@ -128,7 +117,6 @@ ApplyModal.propTypes = { onApply: PT.func.isRequired, onCancel: PT.func.isRequired, setRoles: PT.func.isRequired, - toggleRole: PT.func.isRequired, selectedRoles: PT.arrayOf(PT.number), }; diff --git a/src/shared/components/ReviewOpportunityDetailsPage/ChallengeSpecTab/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/ChallengeSpecTab/index.jsx index 37c0d832c5..d501a707a4 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/ChallengeSpecTab/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/ChallengeSpecTab/index.jsx @@ -12,7 +12,7 @@ import './styles.scss'; const ChallengeSpecTab = ({ challenge }) => (
{ - challenge.detailedRequirements + challenge.description && (

@@ -21,7 +21,7 @@ const ChallengeSpecTab = ({ challenge }) => (
( -
+

diff --git a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss index 1aa3016218..7c13c67016 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss +++ b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss @@ -1,6 +1,6 @@ @import "~styles/mixins"; -.comtainer { +.container { background: $tc-gray-neutral-dark; width: 100%; display: flex; diff --git a/src/shared/components/ReviewOpportunityDetailsPage/Header/ApplyTime/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/Header/ApplyTime/index.jsx index cc48ef7541..0013a94009 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/Header/ApplyTime/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/Header/ApplyTime/index.jsx @@ -51,7 +51,7 @@ const ApplyTime = ({ disabled={!timeLeft || !openPositions} onClick={() => onApply()} > - {hasApplied ? 'Manage Applications' : 'Apply for review'} + {hasApplied ? 'View Application' : 'Apply for review'}

diff --git a/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx index c0a4db58c7..a258fa12a9 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx @@ -22,9 +22,9 @@ const { formatDuration } = time; * @return {Object} The rendered React element */ const renderPhase = phase => ( -
+
- {phase.type} + {phase.name}
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/Header/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/Header/index.jsx index 2ffe5f2382..91709196b1 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/Header/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/Header/index.jsx @@ -31,7 +31,7 @@ const Header = ({ _.toString(app.handle) === _.toString(handle) && app.status !== 'Cancelled'))} + hasApplied={Boolean(_.find(details.applications, app => _.toString(app.handle) === _.toString(handle) && app.status !== 'CANCELLED'))} startDate={details.startDate} completed={details.openPositions === 0} /> diff --git a/src/shared/components/ReviewOpportunityDetailsPage/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/index.jsx index 7e776f7147..8e63b3005c 100644 --- a/src/shared/components/ReviewOpportunityDetailsPage/index.jsx +++ b/src/shared/components/ReviewOpportunityDetailsPage/index.jsx @@ -42,14 +42,14 @@ const ReviewOpportunityDetailsPage = ({

- {details.challenge.title} + {details.challenge.name}

Review Opportunities
- {details.challenge.type} + {typeof details.challenge.type === 'object' ? details.challenge.type.name : details.challenge.type}
@@ -71,7 +71,7 @@ const ReviewOpportunityDetailsPage = ({ > REVIEW APPLICATIONS {' '} - {`(${details.applications ? details.applications.filter(app => app.status !== 'Cancelled').length : 0})`} + {`(${details.applications ? details.applications.filter(app => app.status !== 'CANCELLED').length : 0})`}
diff --git a/src/shared/components/SubmissionManagement/RatingsListModal/index.jsx b/src/shared/components/SubmissionManagement/RatingsListModal/index.jsx index da0db39153..fcf827215a 100644 --- a/src/shared/components/SubmissionManagement/RatingsListModal/index.jsx +++ b/src/shared/components/SubmissionManagement/RatingsListModal/index.jsx @@ -27,46 +27,52 @@ const RatingsListModal = ({ onCancel, submissionId, challengeId, - getReviewTypesList, - getChallengeResources, - getSubmissionInformation, + getSubmissionScores, }) => { const [reviews, setReviews] = useState([]); const [loading, setLoading] = useState(false); - const enrichSources = useCallback(async (submissionReviews, reviewSummation) => { - const reviewTypes = await getReviewTypesList(); - const resources = await getChallengeResources(challengeId); - - const finalReview = { - reviewType: 'Final score', - reviewer: '', - score: reviewSummation ? reviewSummation.aggregateScore : 'N/A', - isPassing: reviewSummation ? reviewSummation.isPassing : undefined, - }; - - return [...submissionReviews.map((review) => { - const reviewType = reviewTypes.find(rt => rt.id === review.typeId); - const reviewer = resources - .find(resource => resource.memberHandle === review.reviewerId) || SystemReviewers.Default; - return { - ...review, - reviewType: reviewType ? reviewType.name : '', - reviewer, - }; - }), finalReview]; - }, [challengeId, getReviewTypesList, getChallengeResources]); - - const getSubmission = useCallback(async () => { - const submissionInfo = await getSubmissionInformation(submissionId); - setReviews(await enrichSources(submissionInfo.review, submissionInfo.reviewSummation[0])); - setLoading(false); - }, [submissionId, getSubmissionInformation, enrichSources]); + const formatScoreValue = useCallback((value) => { + if (_.isNil(value)) { + return 'N/A'; + } + if (Number.isFinite(value)) { + return Number.isInteger(value) ? `${value}` : value.toFixed(2); + } + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return Number.isInteger(numeric) ? `${numeric}` : numeric.toFixed(2); + } + return `${value}`; + }, []); + + const loadScores = useCallback(async () => { + if (!submissionId) { + setReviews([]); + return; + } + setLoading(true); + try { + const response = await getSubmissionScores(submissionId, challengeId); + const normalized = Array.isArray(response) ? response : []; + const decorated = normalized.map((entry, index) => ({ + id: entry.id || `${submissionId}-${index}`, + reviewType: entry.label || entry.reviewType || 'Score', + reviewer: entry.reviewer || SystemReviewers.Default, + score: formatScoreValue(entry.score), + isPassing: typeof entry.isPassing === 'boolean' ? entry.isPassing : null, + })); + setReviews(decorated); + } catch (error) { + setReviews([]); + } finally { + setLoading(false); + } + }, [submissionId, challengeId, getSubmissionScores, formatScoreValue]); useEffect(() => { - setLoading(true); - getSubmission(); - }, [submissionId, getSubmission]); + loadScores(); + }, [loadScores]); return ( onCancel()} theme={theme}> @@ -131,18 +137,14 @@ RatingsListModal.defaultProps = { onCancel: () => {}, submissionId: '', challengeId: '', - getReviewTypesList: _.noop, - getChallengeResources: _.noop, - getSubmissionInformation: _.noop, + getSubmissionScores: _.noop, }; RatingsListModal.propTypes = { onCancel: PropTypes.func, submissionId: PropTypes.string, challengeId: PropTypes.string, - getReviewTypesList: PropTypes.func, - getChallengeResources: PropTypes.func, - getSubmissionInformation: PropTypes.func, + getSubmissionScores: PropTypes.func, }; export default RatingsListModal; diff --git a/src/shared/components/SubmissionManagement/Submission/index.jsx b/src/shared/components/SubmissionManagement/Submission/index.jsx index 9f3b771603..27b82fe3db 100644 --- a/src/shared/components/SubmissionManagement/Submission/index.jsx +++ b/src/shared/components/SubmissionManagement/Submission/index.jsx @@ -14,7 +14,7 @@ import _ from 'lodash'; import moment from 'moment'; import React from 'react'; -import { COMPETITION_TRACKS, CHALLENGE_STATUS, safeForDownload } from 'utils/tc'; +import { CHALLENGE_STATUS, COMPETITION_TRACKS, safeForDownload } from 'utils/tc'; import PT from 'prop-types'; diff --git a/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx b/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx index 9ab0c2314a..6cbecd938e 100644 --- a/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx +++ b/src/shared/components/SubmissionManagement/SubmissionManagement/index.jsx @@ -22,6 +22,7 @@ import { Link } from 'topcoder-react-utils'; import LeftArrow from 'assets/images/arrow-prev-green.svg'; import { PrimaryButton } from 'topcoder-react-ui-kit'; import { phaseEndDate } from 'utils/challenge-listing/helper'; +import { getTrackName } from 'utils/challenge'; import SubmissionsTable from '../SubmissionsTable'; import style from './styles.scss'; @@ -41,14 +42,13 @@ export default function SubmissionManagement(props) { submissionPhaseStartDate, onDownloadArtifacts, getSubmissionArtifacts, - getSubmissionInformation, - getReviewTypesList, - getChallengeResources, + getSubmissionScores, } = props; const { track } = challenge; + const trackName = getTrackName(track); - const challengeType = track.toLowerCase(); + const challengeType = (trackName || '').toLowerCase(); const isDesign = challengeType === 'design'; const isDevelop = challengeType === 'development'; @@ -57,6 +57,7 @@ export default function SubmissionManagement(props) { .sort((a, b) => moment(a.scheduledEndDate).diff(b.scheduledEndDate))[0]; const submissionPhase = challenge.phases.filter(p => p.name === 'Submission')[0]; const submissionEndDate = submissionPhase && phaseEndDate(submissionPhase); + const isSubmissionPhaseOpen = Boolean(submissionPhase && submissionPhase.isOpen); const now = moment(); const end = moment(currentPhase && currentPhase.scheduledEndDate); @@ -77,9 +78,7 @@ export default function SubmissionManagement(props) { onShowDetails, onDownloadArtifacts, getSubmissionArtifacts, - getSubmissionInformation, - getReviewTypesList, - getChallengeResources, + getSubmissionScores, }; return (
@@ -129,7 +128,7 @@ export default function SubmissionManagement(props) { } { - challenge.status !== 'Completed' ? ( + challenge.status !== 'COMPLETED' ? (

Current Deadline Ends: {' '} @@ -184,7 +183,7 @@ export default function SubmissionManagement(props) { challenge={challenge} submissionObjects={submissions} showDetails={showDetails} - track={track} + track={trackName} status={challenge.status} submissionPhaseStartDate={submissionPhaseStartDate} {...componentConfig} @@ -192,7 +191,7 @@ export default function SubmissionManagement(props) { ) }

- {now.isBefore(submissionEndDate) && ( + {isSubmissionPhaseOpen && now.isBefore(submissionEndDate) && (
{ @@ -159,11 +157,9 @@ export default function SubmissionsTable(props) { setSubmissionId(''); setShowRatingsListModal(false); }} - getReviewTypesList={getReviewTypesList} - getChallengeResources={getChallengeResources} submissionId={submissionId} challengeId={challenge.id} - getSubmissionInformation={getSubmissionInformation} + getSubmissionScores={getSubmissionScores} /> )}
@@ -189,9 +185,7 @@ SubmissionsTable.defaultProps = { getSubmissionArtifacts: _.noop, onlineReviewUrl: '', helpPageUrl: '', - getReviewTypesList: _.noop, - getChallengeResources: _.noop, - getSubmissionInformation: _.noop, + getSubmissionScores: _.noop, }; SubmissionsTable.propTypes = { @@ -208,7 +202,5 @@ SubmissionsTable.propTypes = { getSubmissionArtifacts: PT.func, status: PT.string.isRequired, submissionPhaseStartDate: PT.string.isRequired, - getReviewTypesList: PT.func, - getChallengeResources: PT.func, - getSubmissionInformation: PT.func, + getSubmissionScores: PT.func, }; diff --git a/src/shared/components/SubmissionPage/Submit/index.jsx b/src/shared/components/SubmissionPage/Submit/index.jsx index 708182ea40..662fef81ed 100644 --- a/src/shared/components/SubmissionPage/Submit/index.jsx +++ b/src/shared/components/SubmissionPage/Submit/index.jsx @@ -78,13 +78,13 @@ class Submit extends React.Component { // Submission type logic if (checkpoint && checkpoint.isOpen) { - subType = 'Checkpoint Submission'; + subType = 'CHECKPOINT_SUBMISSION'; } else if (checkpoint && !checkpoint.isOpen && submission && submission.isOpen) { - subType = 'Contest Submission'; + subType = 'CONTEST_SUBMISSION'; } else if (finalFix && finalFix.isOpen) { - subType = 'Studio Final Fix Submission'; + subType = 'STUDIO_FINAL_FIX_SUBMISSION'; } else { - subType = 'Contest Submission'; + subType = 'CONTEST_SUBMISSION'; } return subType; diff --git a/src/shared/components/SubmissionPage/index.jsx b/src/shared/components/SubmissionPage/index.jsx index fa4d339f5f..bd4dded61b 100644 --- a/src/shared/components/SubmissionPage/index.jsx +++ b/src/shared/components/SubmissionPage/index.jsx @@ -7,6 +7,8 @@ import React from 'react'; import PT from 'prop-types'; import _ from 'lodash'; +import { CHALLENGE_STATUS } from 'utils/tc'; +import { hasOpenSubmissionPhase } from 'utils/challengePhases'; import Header from './Header'; import Submit from './Submit'; import './styles.scss'; @@ -25,9 +27,8 @@ function SubmissionsPage(props) { handle, } = props; - const submissionEnded = status === 'COMPLETED' - || (!_.some(phases, { name: 'Submission', isOpen: true }) - && !_.some(phases, { name: 'Checkpoint Submission', isOpen: true })); + const submissionEnded = status === CHALLENGE_STATUS.COMPLETED + || !hasOpenSubmissionPhase(phases); const hasFirstPlacement = !_.isEmpty(winners) && _.some(winners, { placement: 1, handle }); diff --git a/src/shared/components/TrackIcon/index.jsx b/src/shared/components/TrackIcon/index.jsx index 33ccf15e72..fcf37779c9 100644 --- a/src/shared/components/TrackIcon/index.jsx +++ b/src/shared/components/TrackIcon/index.jsx @@ -13,7 +13,8 @@ export default function TrackIcon({ challengesUrl, }) { const TCO_URL = `${MAIN_URL}/tco`; - const trackStyle = track.replace(' ', '-').toLowerCase(); + const trackName = (track && typeof track === 'object') ? (track.name || '') : (track || ''); + const trackStyle = trackName.replace(' ', '-').toLowerCase(); let abbreviationStyle = type.abbreviation; if (['CH', 'F2F', 'TSK', 'MM'].indexOf(abbreviationStyle) < 0) { abbreviationStyle = ''; diff --git a/src/shared/components/challenge-detail/Header/TabSelector/index.jsx b/src/shared/components/challenge-detail/Header/TabSelector/index.jsx index 9c3e7cb300..a02f2177b5 100644 --- a/src/shared/components/challenge-detail/Header/TabSelector/index.jsx +++ b/src/shared/components/challenge-detail/Header/TabSelector/index.jsx @@ -16,6 +16,7 @@ import ArrowIcon from 'assets/images/ico-arrow-down.svg'; import CloseIcon from 'assets/images/icon-close-green.svg'; import SortIcon from 'assets/images/icon-sort-mobile.svg'; +import { getTypeName } from 'utils/challenge'; import style from './style.scss'; function getSelectorStyle(selectedView, currentView) { @@ -57,7 +58,7 @@ export default function ChallengeViewSelector(props) { const [isTabClosed, setIsTabClosed] = useState(true); const [expanded, setExpanded] = useState(false); const [selectedSortOption, setSelectedSortOption] = useState(); - const isF2F = type === 'First2Finish'; + const isF2F = getTypeName({ type }) === 'First2Finish'; const isBugHunt = _.includes(tags, 'Bug Hunt'); const isDesign = trackLower === 'design'; @@ -134,7 +135,7 @@ export default function ChallengeViewSelector(props) { const numOfSub = numOfSubmissions + (numOfCheckpointSubmissions || 0); const forumId = _.get(challenge, 'legacy.forumId') || 0; const discuss = _.get(challenge, 'discussions', []).filter(d => ( - d.type === 'challenge' && !_.isEmpty(d.url) + _.toLower(d.type) === 'challenge' && !_.isEmpty(d.url) )); const roles = _.get(challenge, 'userDetails.roles') || []; diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index a7426e0d7c..df568bb997 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import moment from 'moment'; import 'moment-duration-format'; -import { isMM } from 'utils/challenge'; +import { isMM, getTrackName, getTypeName } from 'utils/challenge'; import PT from 'prop-types'; import React, { useMemo } from 'react'; @@ -104,10 +104,16 @@ export default function ChallengeHeader(props) { const sortedAllPhases = _.cloneDeep(allPhases) .sort((a, b) => moment(phaseEndDate(a)).diff(phaseEndDate(b))); - const placementPrizes = _.find(prizeSets, { type: 'placement' }); + const placementPrizes = _.find( + prizeSets, + prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', + ); const { prizes } = placementPrizes || []; - const checkpointPrizes = _.find(prizeSets, { type: 'checkpoint' }); + const checkpointPrizes = _.find( + prizeSets, + prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'checkpoint', + ); let numberOfCheckpointsPrizes = 0; let topCheckPointPrize = 0; if (!_.isEmpty(checkpointPrizes)) { @@ -124,7 +130,7 @@ export default function ChallengeHeader(props) { let registrationEnded = true; const regPhase = phases && phases.registration; - if (status !== 'Completed' && regPhase) { + if (status !== 'COMPLETED' && regPhase) { registrationEnded = !regPhase.isOpen; } @@ -135,7 +141,9 @@ export default function ChallengeHeader(props) { const currentPhases = allOpenPhases .filter(p => !isRegistrationPhase(p))[0]; - const trackLower = track ? track.replace(' ', '-').toLowerCase() : 'design'; + const trackName = getTrackName(track); + const typeName = getTypeName(type); + const trackLower = trackName ? trackName.replace(' ', '-').toLowerCase() : 'design'; const eventNames = (events || []).map((event => (event.eventName || '').toUpperCase())); @@ -235,7 +243,7 @@ export default function ChallengeHeader(props) { if (trackLower === 'quality-assurance') { relevantPhases = _.filter(relevantPhases, p => !(p.name.toLowerCase().includes('specification submission') || p.name.toLowerCase().includes('specification review'))); } - if (type === 'First2Finish' && status === 'Completed') { + if (typeName === 'First2Finish' && status === 'COMPLETED') { const phases2 = allPhases.filter(p => p.name === 'Iterative Review' && !p.isOpen); const endPhaseDate = Math.max(...phases2.map(d => phaseEndDate(d))); relevantPhases = _.filter(relevantPhases, p => (p.name.toLowerCase().includes('registration') @@ -319,7 +327,7 @@ export default function ChallengeHeader(props) { Submit a solution
{ - track === COMPETITION_TRACKS.DES && hasRegistered && !unregistering + trackName === COMPETITION_TRACKS.DES && hasRegistered && !unregistering && hasSubmissions && ( { _.each(entry.submissions, (sub) => { - dates.push(sub.created || null); + dates.push(sub.created || sub.createdAt || null); flatData.push({ ..._.omit(entry, ['submissions']), submissionCount: _.get(entry, 'submissions.length', 0), @@ -40,7 +40,7 @@ export default function Graph({ statisticsData, baseline, awardLine }) { color = data.ratingColor || getRatingColor(data.rating || 0); } return { - x: moment(data.created).valueOf(), + x: moment(data.created || data.createdAt).valueOf(), y: _.max([0, data.score ? (parseFloat(data.score)) : 0]), name: data.handle, color, @@ -160,7 +160,7 @@ export default function Graph({ statisticsData, baseline, awardLine }) {

${currentPointer.customData.submissionCount} submissions

Score: ${this.y}

-

Submitted: ${moment(currentPointer.customData.created).format('MM/DD/YYYY')}

+

Submitted: ${moment(currentPointer.customData.created || currentPointer.customData.createdAt).format('MM/DD/YYYY')}

`; return str; diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsDetail/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsDetail/index.jsx index 71ca8c6fea..821ad64351 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsDetail/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsDetail/index.jsx @@ -7,6 +7,7 @@ import PT from 'prop-types'; import _ from 'lodash'; import cn from 'classnames'; import sortList from 'utils/challenge-detail/sort'; +import { getSubmissionStatus } from 'utils/challenge-detail/submission-status'; import Tooltip from 'components/Tooltip'; import IconClose from 'assets/images/icon-close-green.svg'; @@ -145,6 +146,9 @@ class SubmissionsDetailView extends React.Component { const { onCancel, submission, onSortChange } = this.props; let { finalScore } = submission; const { sortedSubmissions } = this.state; + const { isAccepted } = getSubmissionStatus(submission); + const finalStatusStyleName = isAccepted ? 'status-complete' : 'status-in-queue'; + const finalStatusLabel = isAccepted ? 'Complete' : 'In Queue'; const { field, sort } = this.getSubmissionsSortParam(); const revertSort = (sort === 'desc') ? 'asc' : 'desc'; @@ -373,9 +377,7 @@ class SubmissionsDetailView extends React.Component {
Status
- {submission.provisionalScoringIsCompleted ? ( - Complete - ) : (In Queue)} + {finalStatusLabel}
diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index 368e00cf1e..3a86b25541 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -10,6 +10,7 @@ import { PrimaryButton, Modal } from 'topcoder-react-ui-kit'; import PT from 'prop-types'; import { services } from 'topcoder-react-lib'; import sortList from 'utils/challenge-detail/sort'; +import { getSubmissionStatus } from 'utils/challenge-detail/submission-status'; import IconClose from 'assets/images/icon-close-green.svg'; import DateSortIcon from 'assets/images/icon-date-sort.svg'; @@ -25,6 +26,43 @@ import style from './styles.scss'; const { getService } = services.submissions; +const collectReviewSummations = (submission) => { + const summations = []; + if (!submission) { + return summations; + } + if (Array.isArray(submission.reviewSummations)) { + summations.push(...submission.reviewSummations); + } + if (Array.isArray(submission.reviewSummation)) { + summations.push(...submission.reviewSummation); + } + return summations; +}; + +const getReviewSummationSubmissionId = (submission) => { + const summations = collectReviewSummations(submission); + const match = _.find(summations, s => s && !_.isNil(s.submissionId)); + if (!match) { + return null; + } + return `${match.submissionId}`; +}; + +const getDisplaySubmissionId = (submission) => { + const fromSummation = getReviewSummationSubmissionId(submission); + if (fromSummation) { + return fromSummation; + } + if (submission && !_.isNil(submission.submissionId)) { + return `${submission.submissionId}`; + } + if (submission && !_.isNil(submission.id)) { + return `${submission.id}`; + } + return ''; +}; + class SubmissionsListView extends React.Component { constructor(props) { @@ -100,16 +138,27 @@ class SubmissionsListView extends React.Component { return sortList(submissions, field, sort, (a, b) => { let valueA = 0; let valueB = 0; - const valueIsString = false; + let valueIsString = false; switch (field) { case 'Submission ID': { - valueA = a.id; - valueB = b.id; + const idA = getDisplaySubmissionId(a); + const idB = getDisplaySubmissionId(b); + const numericA = Number(idA); + const numericB = Number(idB); + const useNumericSort = idA !== '' && idB !== '' && _.isFinite(numericA) && _.isFinite(numericB); + if (useNumericSort) { + valueA = numericA; + valueB = numericB; + } else { + valueA = idA; + valueB = idB; + valueIsString = true; + } break; } case 'Status': { - valueA = a.provisionalScoringIsCompleted; - valueB = b.provisionalScoringIsCompleted; + valueA = getSubmissionStatus(a).isAccepted ? 1 : 0; + valueB = getSubmissionStatus(b).isAccepted ? 1 : 0; break; } case 'Final': { @@ -391,8 +440,15 @@ class SubmissionsListView extends React.Component { } else { provisionalScore = 'N/A'; } + const { isAccepted } = getSubmissionStatus(mySubmission); + const statusStyleName = isAccepted ? 'accepted' : 'queue'; + const statusLabel = isAccepted ? 'Accepted' : 'In Queue'; + const displaySubmissionId = getDisplaySubmissionId(mySubmission); return ( -
+
Submission Id
- {mySubmission.id} + {displaySubmissionId}
Status
- {mySubmission.provisionalScoringIsCompleted ? ( - Accepted - ) : In Queue} + {statusLabel}
diff --git a/src/shared/components/challenge-detail/MySubmissions/index.jsx b/src/shared/components/challenge-detail/MySubmissions/index.jsx index 1cf44d994e..d8ba171f95 100644 --- a/src/shared/components/challenge-detail/MySubmissions/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/index.jsx @@ -197,7 +197,10 @@ MySubmissionsView.propTypes = { submissionEnded: PT.bool.isRequired, isMM: PT.bool.isRequired, isLegacyMM: PT.bool.isRequired, - loadingMMSubmissionsForChallengeId: PT.string.isRequired, + loadingMMSubmissionsForChallengeId: PT.oneOfType([ + PT.string, + PT.oneOf([null]), + ]).isRequired, auth: PT.shape().isRequired, loadMMSubmissions: PT.func.isRequired, mySubmissions: PT.arrayOf(PT.shape()).isRequired, diff --git a/src/shared/components/challenge-detail/RecommendedActiveChallenges/ChallengesCard/index.jsx b/src/shared/components/challenge-detail/RecommendedActiveChallenges/ChallengesCard/index.jsx index 3bdeb882f6..b369983490 100644 --- a/src/shared/components/challenge-detail/RecommendedActiveChallenges/ChallengesCard/index.jsx +++ b/src/shared/components/challenge-detail/RecommendedActiveChallenges/ChallengesCard/index.jsx @@ -83,7 +83,7 @@ export default function ChallengesCard({
- {challenge.status === 'Active' ? 'Ends ' : 'Ended '} + {challenge.status === 'ACTIVE' ? 'Ends ' : 'Ended '} {getEndDate(challenge)}
diff --git a/src/shared/components/challenge-detail/Registrants/index.jsx b/src/shared/components/challenge-detail/Registrants/index.jsx index 5c21256bb1..0e90622b41 100644 --- a/src/shared/components/challenge-detail/Registrants/index.jsx +++ b/src/shared/components/challenge-detail/Registrants/index.jsx @@ -9,6 +9,7 @@ import moment from 'moment'; import _ from 'lodash'; import cn from 'classnames'; import { getRatingLevel } from 'utils/tc'; +import { getTrackName } from 'utils/challenge'; import sortList from 'utils/challenge-detail/sort'; import DateSortIcon from 'assets/images/icon-date-sort.svg'; @@ -286,9 +287,12 @@ export default class Registrants extends React.Component { const { field, sort } = this.getRegistrantsSortParam(); const revertSort = (sort === 'desc') ? 'asc' : 'desc'; - const isDesign = track.toLowerCase() === 'design'; + const isDesign = (getTrackName(track) || '').toLowerCase() === 'design'; - const placementPrizes = _.find(prizeSets, { type: 'placement' }); + const placementPrizes = _.find( + prizeSets, + prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', + ); const { prizes } = placementPrizes || []; const checkpoints = challenge.checkpoints || []; diff --git a/src/shared/components/challenge-detail/Specification/index.jsx b/src/shared/components/challenge-detail/Specification/index.jsx index d6319fd682..60516359dd 100644 --- a/src/shared/components/challenge-detail/Specification/index.jsx +++ b/src/shared/components/challenge-detail/Specification/index.jsx @@ -11,7 +11,7 @@ import ToolbarConnector from 'components/Editor/Connector'; import React from 'react'; import Sticky from 'react-stickynode'; import { config } from 'topcoder-react-utils'; -import { isMM } from 'utils/challenge'; +import { isMM, getTrackName } from 'utils/challenge'; import PT from 'prop-types'; import { DangerButton } from 'topcoder-react-ui-kit'; @@ -83,12 +83,13 @@ export default function ChallengeDetailsView(props) { } const discuss = _.get(challenge, 'discussions', []).filter(d => ( - d.type === 'challenge' && !_.isEmpty(d.url) + _.toLower(d.type) === 'challenge' && !_.isEmpty(d.url) )); let forumLink = ''; if (forumId > 0) { - forumLink = track.toLowerCase() === 'design' + const trackName = (getTrackName(track) || '').toLowerCase(); + forumLink = trackName === 'design' ? `/?module=ThreadList&forumID=${forumId}` : `/?module=Category&categoryID=${forumId}`; } @@ -108,7 +109,7 @@ export default function ChallengeDetailsView(props) { } let accentedStyle = ''; - switch (track.toLowerCase()) { + switch ((getTrackName(track) || '').toLowerCase()) { case 'design': accentedStyle = 'challenge-specs-design'; break; @@ -187,7 +188,7 @@ export default function ChallengeDetailsView(props) {
{ - track.toLowerCase() !== 'design' + (getTrackName(track) || '').toLowerCase() !== 'design' ? (
{ @@ -366,8 +367,8 @@ export default function ChallengeDetailsView(props) { legacyId={legacyId} forumLink={forumLink} discuss={discuss} - isDesign={track.toLowerCase() === 'design'} - isDevelop={track.toLowerCase() === 'development'} + isDesign={(getTrackName(track) || '').toLowerCase() === 'design'} + isDevelop={(getTrackName(track) || '').toLowerCase() === 'development'} eventDetail={_.isEmpty(events) ? null : events[0]} isMM={isMM(challenge)} terms={terms} @@ -417,7 +418,7 @@ ChallengeDetailsView.propTypes = { forumId: PT.number, selfService: PT.bool, }), - track: PT.string.isRequired, + track: PT.oneOfType([PT.string, PT.shape()]).isRequired, legacyId: PT.oneOfType([PT.string, PT.number]), groups: PT.any, reviewType: PT.string, diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/SubmissionHistoryRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/SubmissionHistoryRow/index.jsx index 45d244cac8..54b7289dda 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/SubmissionHistoryRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/SubmissionHistoryRow/index.jsx @@ -36,26 +36,40 @@ export default function SubmissionHistoryRow({ }) { // todo: hide download button until update submissions API const hideDownloadForMMRDM = true; + const parseScore = (value) => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + const provisionalScoreValue = parseScore(provisionalScore); + const finalScoreValue = parseScore(finalScore); + const submissionMoment = submissionTime ? moment(submissionTime) : null; + const submissionTimeDisplay = submissionMoment + ? `${submissionMoment.format('DD MMM YYYY')} ${submissionMoment.format('HH:mm:ss')}` + : 'N/A'; const getInitialReviewResult = () => { - if (provisionalScore && provisionalScore < 0) return ; + if (status === 'failed') return ; + if (status === 'in-review') return ; + if (status === 'queued') return ; + if (provisionalScoreValue === null) return 'N/A'; + if (provisionalScoreValue < 0) return ; switch (status) { case 'completed': - return provisionalScore; - case 'in-review': - return ; - case 'queued': - return ; - case 'failed': - return ; + return provisionalScoreValue; default: - return provisionalScore === '-' ? 'N/A' : provisionalScore; + return provisionalScoreValue; } }; const getFinalScore = () => { - if (isMM && finalScore && finalScore > -1 && isReviewPhaseComplete) { - return finalScore; + if (!isReviewPhaseComplete) { + return 'N/A'; + } + if (finalScoreValue === null) { + return 'N/A'; } - return 'N/A'; + if (finalScoreValue < 0) { + return 0; + } + return finalScoreValue; }; return ( @@ -80,7 +94,7 @@ export default function SubmissionHistoryRow({
TIME
- {moment(submissionTime).format('DD MMM YYYY')} {moment(submissionTime).format('HH:mm:ss')} + {submissionTimeDisplay}
{ @@ -129,13 +143,18 @@ SubmissionHistoryRow.propTypes = { finalScore: PT.oneOfType([ PT.number, PT.string, + PT.oneOf([null]), ]), status: PT.string.isRequired, provisionalScore: PT.oneOfType([ PT.number, PT.string, + PT.oneOf([null]), ]), - submissionTime: PT.string.isRequired, + submissionTime: PT.oneOfType([ + PT.string, + PT.oneOf([null]), + ]).isRequired, challengeStatus: PT.string.isRequired, isReviewPhaseComplete: PT.bool, auth: PT.shape().isRequired, diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index b2fc9cca9b..9ee00ea4e1 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -19,46 +19,78 @@ import SubmissionHistoryRow from './SubmissionHistoryRow'; import style from './style.scss'; export default function SubmissionRow({ - isMM, isRDM, openHistory, member, submissions, score, toggleHistory, challengeStatus, + isMM, isRDM, openHistory, member, submissions, toggleHistory, challengeStatus, isReviewPhaseComplete, finalRank, provisionalRank, onShowPopup, rating, viewAsTable, numWinners, auth, isLoggedIn, }) { + const submissionList = Array.isArray(submissions) ? submissions : []; + const latestSubmission = submissionList[0] || {}; const { - submissionTime, provisionalScore, status, submissionId, - } = submissions[0]; - let { finalScore } = submissions[0]; - finalScore = (!finalScore && finalScore < 0) || !isReviewPhaseComplete ? '-' : finalScore; - let initialScore; - if (provisionalScore >= 0 || provisionalScore === -1) { - initialScore = provisionalScore; - } + status, + submissionId, + submissionTime, + } = latestSubmission; + + const parseScore = (value) => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + + const provisionalScore = parseScore(_.get(latestSubmission, 'provisionalScore')); + const finalScore = parseScore(_.get(latestSubmission, 'finalScore')); const getInitialReviewResult = () => { - const s = isMM ? _.get(score, 'provisional', initialScore) : initialScore; - if (s && s < 0) return ; - switch (status) { - case 'completed': - return s; - case 'in-review': - return ; - case 'queued': - return ; - case 'failed': - return ; - default: - return s; + if (status === 'failed') { + return ; + } + if (status === 'in-review') { + return ; + } + if (status === 'queued') { + return ; + } + if (_.isNil(provisionalScore)) { + return 'N/A'; + } + if (provisionalScore < 0) { + return ; } + return provisionalScore; }; const getFinalReviewResult = () => { - const s = isMM && isReviewPhaseComplete ? _.get(score, 'final', finalScore) : finalScore; - if (isReviewPhaseComplete) { - if (s && s < 0) return 0; - return s; + if (!isReviewPhaseComplete) { + return 'N/A'; } - return 'N/A'; + if (_.isNil(finalScore)) { + return 'N/A'; + } + if (finalScore < 0) { + return 0; + } + return finalScore; }; + const initialReviewResult = getInitialReviewResult(); + const finalReviewResult = getFinalReviewResult(); + + const submissionMoment = submissionTime ? moment(submissionTime) : null; + const submissionDateDisplay = submissionMoment + ? `${submissionMoment.format('DD MMM YYYY')} ${submissionMoment.format('HH:mm:ss')}` + : 'N/A'; + + const finalRankDisplay = (isReviewPhaseComplete && _.isFinite(finalRank)) ? finalRank : 'N/A'; + const provisionalRankDisplay = _.isFinite(provisionalRank) ? provisionalRank : 'N/A'; + const ratingDisplay = _.isFinite(rating) ? rating : '-'; + const ratingLevelStyle = `col level-${getRatingLevel(rating)}`; + const memberHandle = member || ''; + const memberDisplay = memberHandle || '-'; + const memberProfileUrl = memberHandle ? `${window.origin}/members/${memberHandle}` : null; + const memberLinkTarget = `${_.includes(window.origin, 'www') ? '_self' : '_blank'}`; + const memberForHistory = memberHandle || memberDisplay; + const latestSubmissionId = submissionId || 'N/A'; + const submissionCount = submissionList.length; + return (
@@ -67,14 +99,12 @@ export default function SubmissionRow({
FINAL RANK
- { - isReviewPhaseComplete ? finalRank || 'N/A' : 'N/A' - } + {finalRankDisplay}
PROVISIONAL RANK
- { provisionalRank || 'N/A' } + { provisionalRankDisplay }
@@ -82,37 +112,43 @@ export default function SubmissionRow({ }
RATING
- - {rating || '-'} + + {ratingDisplay}
USERNAME
- - {member || '-'} - + { + memberProfileUrl ? ( + + {memberDisplay} + + ) : ( + {memberDisplay} + ) + }
FINAL SCORE
- {getFinalReviewResult()} + {finalReviewResult}
PROVISIONAL SCORE
- {getInitialReviewResult() ? getInitialReviewResult() : 'N/A'} + {initialReviewResult}
SUBMISSION DATE
- {moment(submissionTime).format('DD MMM YYYY')} {moment(submissionTime).format('HH:mm:ss')} + {submissionDateDisplay}
@@ -123,7 +159,7 @@ export default function SubmissionRow({ > History ( - {submissions.length} + {submissionCount} ) @@ -143,7 +179,7 @@ export default function SubmissionRow({

- Latest Submission: {submissionId} + Latest Submission: {latestSubmissionId}
@@ -182,17 +218,17 @@ export default function SubmissionRow({
{ - submissions.map((submissionHistory, index) => ( + submissionList.map((submissionHistory, index) => ( {}, - score: {}, isReviewPhaseComplete: false, finalRank: null, provisionalRank: null, @@ -234,29 +269,20 @@ SubmissionRow.propTypes = { provisionalScore: PT.oneOfType([ PT.number, PT.string, + PT.oneOf([null]), ]), finalScore: PT.oneOfType([ PT.number, PT.string, - ]), - initialScore: PT.oneOfType([ - PT.number, - PT.string, + PT.oneOf([null]), ]), status: PT.string.isRequired, submissionId: PT.string.isRequired, - submissionTime: PT.string.isRequired, - })).isRequired, - score: PT.shape({ - final: PT.oneOfType([ - PT.number, - PT.string, - ]), - provisional: PT.oneOfType([ - PT.number, + submissionTime: PT.oneOfType([ PT.string, - ]), - }), + PT.oneOf([null]), + ]).isRequired, + })).isRequired, rating: PT.number, toggleHistory: PT.func, isReviewPhaseComplete: PT.bool, diff --git a/src/shared/components/challenge-detail/Submissions/index.jsx b/src/shared/components/challenge-detail/Submissions/index.jsx index 377259daaa..796dd3a0db 100644 --- a/src/shared/components/challenge-detail/Submissions/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/index.jsx @@ -6,7 +6,12 @@ import React from 'react'; import PT from 'prop-types'; import moment from 'moment'; -import { isMM as checkIsMM, isRDM as checkIsRDM } from 'utils/challenge'; +import { + isMM as checkIsMM, + isRDM as checkIsRDM, + getTrackName, + getTypeName, +} from 'utils/challenge'; import _ from 'lodash'; import { connect } from 'react-redux'; import { config } from 'topcoder-react-utils'; @@ -118,11 +123,11 @@ class SubmissionsComponent extends React.Component { let score = 'N/A'; const { challenge } = this.props; if (!_.isEmpty(submission.review) - && !_.isEmpty(submission.review[0]) - && submission.review[0].score - && (challenge.status === 'Completed' + && !_.isEmpty(submission) + && submission.initialScore + && (challenge.status === 'COMPLETED' || (_.includes(challenge.tags, 'Innovation Challenge') && _.find(challenge.metadata, { name: 'show_data_dashboard' })))) { - score = Number(submission.review[0].score).toFixed(2); + score = Number(submission.initialScore).toFixed(2); } return score; } @@ -175,7 +180,9 @@ class SubmissionsComponent extends React.Component { updateSortedSubmissions() { const isMM = this.isMM(); const { submissions, mmSubmissions } = this.props; - const sortedSubmissions = _.cloneDeep(isMM ? mmSubmissions : submissions); + const source = isMM ? mmSubmissions : submissions; + const sourceList = Array.isArray(source) ? source : []; + const sortedSubmissions = _.cloneDeep(sourceList); this.sortSubmissions(sortedSubmissions); this.setState({ sortedSubmissions }); } @@ -185,16 +192,36 @@ class SubmissionsComponent extends React.Component { * @param {Array} submissions array of submission */ sortSubmissions(submissions) { + if (!Array.isArray(submissions) || !submissions.length) { + return; + } const isMM = this.isMM(); const isReviewPhaseComplete = this.checkIsReviewPhaseComplete(); const { field, sort } = this.getSubmissionsSortParam(isMM, isReviewPhaseComplete); let isHaveFinalScore = false; - if (field === 'Initial Score' || 'Final Score') { + if (field === 'Initial Score' || field === 'Final Score') { isHaveFinalScore = _.some(submissions, s => !_.isNil( - s.reviewSummation && s.reviewSummation[0].aggregateScore, + s.review && s.finalScore, )); } - return sortList(submissions, field, sort, (a, b) => { + const toSubmissionTime = (entry) => { + const latest = _.get(entry, ['submissions', 0]); + if (!latest) { + return null; + } + const { submissionTime } = latest; + if (!submissionTime) { + return null; + } + const timestamp = new Date(submissionTime).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; + }; + const toRankValue = rank => (_.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER); + const toScoreValue = (score) => { + const numeric = Number(score); + return Number.isFinite(numeric) ? numeric : null; + }; + sortList(submissions, field, sort, (a, b) => { let valueA = 0; let valueB = 0; let valueIsString = false; @@ -222,48 +249,50 @@ class SubmissionsComponent extends React.Component { break; } case 'Time': - valueA = new Date(a.submissions && a.submissions[0].submissionTime); - valueB = new Date(b.submissions && b.submissions[0].submissionTime); + valueA = toSubmissionTime(a); + valueB = toSubmissionTime(b); break; case 'Submission Date': { - valueA = new Date(a.created); - valueB = new Date(b.created); + const createdA = a.created || a.createdAt; + const createdB = b.created || b.createdAt; + valueA = createdA ? new Date(createdA).getTime() : null; + valueB = createdB ? new Date(createdB).getTime() : null; break; } case 'Initial Score': { if (isHaveFinalScore) { - valueA = getFinalScore(a); - valueB = getFinalScore(b); + valueA = !_.isEmpty(a.review) && a.finalScore; + valueB = !_.isEmpty(b.review) && b.finalScore; } else if (valueA.score || valueB.score) { // Handle MM formatted scores in a code challenge (PS-295) valueA = Number(valueA.score); valueB = Number(valueB.score); } else { - valueA = !_.isEmpty(a.review) && a.review[0].score; - valueB = !_.isEmpty(b.review) && b.review[0].score; + valueA = !_.isEmpty(a.review) && a.initialScore; + valueB = !_.isEmpty(b.review) && b.initialScore; } break; } case 'Final Rank': { - if (this.checkIsReviewPhaseComplete()) { - valueA = a.finalRank ? a.finalRank : 0; - valueB = b.finalRank ? b.finalRank : 0; + if (isReviewPhaseComplete) { + valueA = toRankValue(_.get(a, 'finalRank')); + valueB = toRankValue(_.get(b, 'finalRank')); } break; } case 'Provisional Rank': { - valueA = a.provisionalRank ? a.provisionalRank : 0; - valueB = b.provisionalRank ? b.provisionalRank : 0; + valueA = toRankValue(_.get(a, 'provisionalRank')); + valueB = toRankValue(_.get(b, 'provisionalRank')); break; } case 'Final Score': { - valueA = getFinalScore(a); - valueB = getFinalScore(b); + valueA = toScoreValue(getFinalScore(a)); + valueB = toScoreValue(getFinalScore(b)); break; } case 'Provisional Score': { - valueA = getProvisionalScore(a); - valueB = getProvisionalScore(b); + valueA = toScoreValue(getProvisionalScore(a)); + valueB = toScoreValue(getProvisionalScore(b)); break; } default: @@ -284,7 +313,8 @@ class SubmissionsComponent extends React.Component { isMM() { const { challenge } = this.props; - return challenge.track.toLowerCase() === 'data science' || checkIsMM(challenge); + const trackName = getTrackName(challenge); + return (trackName || '').toLowerCase() === 'data science' || checkIsMM(challenge); } /** @@ -336,6 +366,8 @@ class SubmissionsComponent extends React.Component { type, tags, } = challenge; + const trackName = getTrackName(track); + const typeName = getTypeName(type); // todo: hide download button until update submissions API const hideDownloadForMMRDM = true; @@ -414,7 +446,7 @@ class SubmissionsComponent extends React.Component {
); - const isF2F = type === 'First2Finish'; + const isF2F = typeName === 'First2Finish'; const isBugHunt = _.includes(tags, 'Bug Hunt'); // copy colorStyle from registrants to submissions @@ -434,7 +466,7 @@ class SubmissionsComponent extends React.Component { } }); - if (track.toLowerCase() === 'design') { + if ((trackName || '').toLowerCase() === 'design') { return challenge.submissionViewable === 'true' ? (
@@ -902,7 +934,7 @@ class SubmissionsComponent extends React.Component { { !isMM && ( sortedSubmissions.map(s => ( -
+
{ !isF2F && !isBugHunt && ( @@ -927,7 +959,7 @@ class SubmissionsComponent extends React.Component {
SUBMISSION DATE

- {moment(s.created).format('MMM DD, YYYY HH:mm')} + {moment(s.created || s.createdAt).format('MMM DD, YYYY HH:mm')}

@@ -940,8 +972,8 @@ class SubmissionsComponent extends React.Component {
FINAL SCORE

{ - (s.reviewSummation && s.reviewSummation[0].aggregateScore && challenge.status === 'Completed') - ? s.reviewSummation[0].aggregateScore.toFixed(2) + (s.review && s.finalScore && challenge.status === 'COMPLETED') + ? Number(s.finalScore).toFixed(2) : 'N/A' }

@@ -1012,7 +1044,10 @@ SubmissionsComponent.propTypes = { submissionHistoryOpen: PT.shape({}).isRequired, loadMMSubmissions: PT.func.isRequired, mmSubmissions: PT.arrayOf(PT.shape()).isRequired, - loadingMMSubmissionsForChallengeId: PT.string.isRequired, + loadingMMSubmissionsForChallengeId: PT.oneOfType([ + PT.string, + PT.oneOf([null]), + ]).isRequired, isLoadingSubmissionInformation: PT.bool, submissionInformation: PT.shape(), loadSubmissionInformation: PT.func.isRequired, diff --git a/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx index 503718e85e..73189a5914 100644 --- a/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx +++ b/src/shared/components/challenge-listing/ChallengeCard/Status/index.jsx @@ -11,6 +11,7 @@ import { getTimeLeft, } from 'utils/challenge-detail/helper'; +import { getTypeName } from 'utils/challenge'; import ChallengeProgressBar from '../../ChallengeProgressBar'; import ProgressBarTooltip from '../../Tooltips/ProgressBarTooltip'; import UserAvatarTooltip from '../../Tooltips/UserAvatarTooltip'; @@ -232,14 +233,14 @@ export default function ChallengeStatus(props) { .filter(p => p.name !== 'Registration' && p.isOpen) .sort((a, b) => moment(a.scheduledEndDate).diff(b.scheduledEndDate))[0]; - if (!statusPhase && type === 'First2Finish' && allPhases.length) { + if (!statusPhase && getTypeName({ type }) === 'First2Finish' && allPhases.length) { statusPhase = _.clone(allPhases[0]); statusPhase.name = 'Submission'; } let phaseMessage = STALLED_MSG; if (statusPhase) phaseMessage = statusPhase.name; - else if (status === 'Draft') phaseMessage = DRAFT_MSG; + else if (status === 'DRAFT') phaseMessage = DRAFT_MSG; const showRegisterInfo = false; @@ -287,7 +288,7 @@ export default function ChallengeStatus(props) { { - status === 'Active' && statusPhase ? ( + status === 'ACTIVE' && statusPhase ? (
diff --git a/src/shared/components/challenge-listing/ChallengeCard/index.jsx b/src/shared/components/challenge-listing/ChallengeCard/index.jsx index 3fb2d7b812..80803a7713 100644 --- a/src/shared/components/challenge-listing/ChallengeCard/index.jsx +++ b/src/shared/components/challenge-listing/ChallengeCard/index.jsx @@ -42,6 +42,7 @@ function ChallengeCard({ id, track, } = challenge; + const trackName = (track && typeof track === 'object') ? (track.name || '') : track; challenge.prize = challenge.prizes || []; @@ -59,12 +60,12 @@ function ChallengeCard({
0 ? challenge.events[0].key : ''} /> @@ -84,7 +85,7 @@ function ChallengeCard({
- {challenge.status === 'Active' ? 'Ends ' : 'Ended '} + {challenge.status === 'ACTIVE' ? 'Ends ' : 'Ended '} {getEndDate(challenge)} { diff --git a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx index 881f5e99b9..3fe0b2e9dc 100644 --- a/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx +++ b/src/shared/components/challenge-listing/Filters/FiltersPanel/index.jsx @@ -485,7 +485,7 @@ export default function FiltersPanel({ events: [], endDateStart: null, startDateEnd: null, - status: 'Active', + status: 'ACTIVE', reviewOpportunityTypes: _.keys(REVIEW_OPPORTUNITY_TYPES), customDate: false, recommended: false, diff --git a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx index 22a21d39bc..7021ad7994 100644 --- a/src/shared/components/challenge-listing/Listing/Bucket/index.jsx +++ b/src/shared/components/challenge-listing/Listing/Bucket/index.jsx @@ -16,6 +16,7 @@ import { import SortingSelectBar from 'components/SortingSelectBar'; import Waypoint from 'react-waypoint'; // import { challenge as challengeUtils } from 'topcoder-react-lib'; +import { getTypeName } from 'utils/challenge'; import CardPlaceholder from '../../placeholders/ChallengeCard'; import ChallengeCard from '../../ChallengeCard'; import NoRecommenderChallengeCard from '../../NoRecommenderChallengeCard'; @@ -85,7 +86,8 @@ export default function Bucket({ if (!_.includes(roles, 'administrator')) { filteredChallenges = sortedChallenges.filter((ch) => { - if (ch.type === 'Task' + const typeName = getTypeName(ch); + if (typeName === 'Task' && ch.task && ch.task.isTask && ch.task.isAssigned @@ -158,7 +160,7 @@ export default function Bucket({ const cards = filteredChallenges.map(challenge => ( { diff --git a/src/shared/components/challenge-listing/Listing/ReviewOpportunityBucket/index.jsx b/src/shared/components/challenge-listing/Listing/ReviewOpportunityBucket/index.jsx index 07cc41627d..b7c8d4912c 100644 --- a/src/shared/components/challenge-listing/Listing/ReviewOpportunityBucket/index.jsx +++ b/src/shared/components/challenge-listing/Listing/ReviewOpportunityBucket/index.jsx @@ -1,21 +1,20 @@ /** * The bucket for review opportunities. */ +import React from 'react'; import _ from 'lodash'; import PT from 'prop-types'; -import React from 'react'; -import Sort from 'utils/challenge-listing/sort'; -import { BUCKET_DATA } from 'utils/challenge-listing/buckets'; -import SortingSelectBar from 'components/SortingSelectBar'; import Waypoint from 'react-waypoint'; -import { challenge as challengeUtils } from 'topcoder-react-lib'; -import CardPlaceholder from '../../placeholders/ChallengeCard'; +import SortingSelectBar from 'components/SortingSelectBar'; +import { BUCKET_DATA } from 'utils/challenge-listing/buckets'; +import Sort from 'utils/challenge-listing/sort'; +import { getReviewOpportunitiesFilterFunction } from 'utils/reviewOpportunities'; + import ReviewOpportunityCard from '../../ReviewOpportunityCard'; +import CardPlaceholder from '../../placeholders/ChallengeCard'; import './style.scss'; -const Filter = challengeUtils.filter; - const NO_RESULTS_MESSAGE = 'No challenges found'; const LOADING_MESSAGE = 'Loading Challenges'; @@ -49,7 +48,7 @@ export default function ReviewOpportunityBucket({ * which avoids reloading the review opportunities from server every time * a filter is changed. */ const filteredOpportunities = sortedOpportunities.filter( - Filter.getReviewOpportunitiesFilterFunction({ + getReviewOpportunitiesFilterFunction({ ...BUCKET_DATA[bucket].filter, // Default bucket filters from utils/buckets.js ...filterState, // User selected filters }, challengeTypes), @@ -59,7 +58,7 @@ export default function ReviewOpportunityBucket({ const cards = filteredOpportunities.map(item => ( { @@ -142,7 +141,7 @@ export default function ReviewOpportunityBucket({ ) } { - loadMore && !loading && filterState.reviewOpportunityTypes.length ? ( + loadMore && !loading ? ( ) : null } diff --git a/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx b/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx index c71550717e..b0b9b86aee 100644 --- a/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx +++ b/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx @@ -3,13 +3,13 @@ * information. Will be contained within a Bucket. */ import _ from 'lodash'; -import { Link } from 'topcoder-react-utils'; import moment from 'moment'; -import React, { useMemo } from 'react'; import PT from 'prop-types'; +import React, { useMemo } from 'react'; +import { Link } from 'topcoder-react-utils'; -import TrackIcon from 'components/TrackIcon'; import Tooltip from 'components/Tooltip'; +import TrackIcon from 'components/TrackIcon'; import { time } from 'topcoder-react-lib'; import { REVIEW_OPPORTUNITY_TYPES } from 'utils/tc'; @@ -18,8 +18,8 @@ import Tags from '../Tags'; import TrackAbbreviationTooltip from '../Tooltips/TrackAbbreviationTooltip'; -import SubmissionsIcon from '../Icons/SubmissionsIcon'; import OpenPositionsIcon from '../Icons/RegistrantsIcon'; +import SubmissionsIcon from '../Icons/SubmissionsIcon'; import './style.scss'; @@ -47,14 +47,16 @@ function ReviewOpportunityCard({ opportunity, challengeType, }) { - const { challenge } = opportunity; + const { challengeData: challenge } = opportunity; let tags = challenge.tags || challenge.technologies; const skills = useMemo(() => _.uniq((challenge.skills || []).map(skill => skill.name)), [ challenge.skills, ]); tags = tags.filter(tag => tag.trim().length); - const { track } = challenge.track; + const { track } = challenge; const start = moment(opportunity.startDate); + const now = moment(); + const isLate = now.isAfter(start); return (
@@ -78,7 +80,7 @@ function ReviewOpportunityCard({ ) /* END - DISABLED UNTIL REVIEW OPPORTUNITY RECEIVE UPDATE TO API V5 */ }
{challenge.title} @@ -156,12 +158,15 @@ function ReviewOpportunityCard({
- Late by
- { start.isAfter() ? formatDuration(start.diff()) : ` ${formatDuration(-start.diff())}` } + {isLate ? 'Late by' : 'Time left'}
+ {isLate + ? formatDuration(now.diff(start)) + : formatDuration(start.diff(now)) + }
to Apply diff --git a/src/shared/components/tc-communities/ChallengesBlock/Card/index.jsx b/src/shared/components/tc-communities/ChallengesBlock/Card/index.jsx index d79b811a07..a33c1943a4 100644 --- a/src/shared/components/tc-communities/ChallengesBlock/Card/index.jsx +++ b/src/shared/components/tc-communities/ChallengesBlock/Card/index.jsx @@ -13,6 +13,7 @@ import { import { Link } from 'topcoder-react-utils'; import { COMPETITION_TRACKS } from 'utils/tc'; +import { getTrackName } from 'utils/challenge'; import './style.scss'; @@ -29,7 +30,7 @@ export default function Card({ } = challenge; let TrackTag; - switch (track.toLowerCase()) { + switch ((getTrackName(track) || '').toLowerCase()) { case 'datasci': case COMPETITION_TRACKS.DS: TrackTag = DataScienceTrackTag; diff --git a/src/shared/containers/Dashboard/ChallengesFeed.jsx b/src/shared/containers/Dashboard/ChallengesFeed.jsx index 113aa5881c..b8ede649ca 100644 --- a/src/shared/containers/Dashboard/ChallengesFeed.jsx +++ b/src/shared/containers/Dashboard/ChallengesFeed.jsx @@ -23,7 +23,7 @@ class ChallengesFeedContainer extends React.Component { perPage: excludeTags && excludeTags.length ? undefined : itemCount, types: ['CH', 'F2F', 'MM'], tracks, - status: 'Active', + status: 'ACTIVE', sortBy: 'updated', sortOrder: 'desc', isLightweight: true, diff --git a/src/shared/containers/ReviewOpportunityDetails.jsx b/src/shared/containers/ReviewOpportunityDetails.jsx index bb40d2f79e..3b6ae8b17c 100644 --- a/src/shared/containers/ReviewOpportunityDetails.jsx +++ b/src/shared/containers/ReviewOpportunityDetails.jsx @@ -10,11 +10,12 @@ import { connect } from 'react-redux'; import { actions, errors } from 'topcoder-react-lib'; import LoadingIndicator from 'components/LoadingIndicator'; -import { activeRoleIds } from 'utils/reviewOpportunities'; import pageActions from 'actions/page/review-opportunity-details'; import ReviewOpportunityDetailsPage from 'components/ReviewOpportunityDetailsPage'; import FailedToLoad from 'components/ReviewOpportunityDetailsPage/FailedToLoad'; import termsActions from 'actions/terms'; +import { goToLogin } from 'utils/tc'; +import { logger } from 'tc-core-library-js'; const { fireErrorMessage } = errors; @@ -25,6 +26,7 @@ class ReviewOpportunityDetailsContainer extends React.Component { componentDidMount() { const { challengeId, + opportunityId, details, isLoadingDetails, loadDetails, @@ -32,19 +34,37 @@ class ReviewOpportunityDetailsContainer extends React.Component { } = this.props; if (!isLoadingDetails && !details) { - loadDetails(challengeId, tokenV3); + loadDetails(challengeId, opportunityId, tokenV3); } else if (details.challenge.id !== challengeId) { - loadDetails(challengeId, tokenV3); + loadDetails(challengeId, opportunityId, tokenV3); } } handleOnHeaderApply() { const { + isLoggedIn, + isReviewer, openTermsModal, terms, termsFailure, toggleApplyModal, } = this.props; + + if (!isLoggedIn) { + goToLogin('community-app-main'); + return; + } + + if (!isReviewer) { + fireErrorMessage( + 'Permission Required', + + You must have a reviewer role to apply for this review opportunity. + , + ); + return; + } + if (termsFailure) { fireErrorMessage('Error Getting Terms Details', ''); return; @@ -56,45 +76,28 @@ class ReviewOpportunityDetailsContainer extends React.Component { } } - handleOnModalApply() { + async handleOnModalApply() { const { - cancelApplications, challengeId, - details, - handle, + opportunityId, loadDetails, - selectedRoles, submitApplications, toggleApplyModal, tokenV3, } = this.props; - const rolesToApply = []; - const rolesToCancel = []; - - const previousRoles = activeRoleIds(details, handle); + try { + // Wait for the submit to finish (and succeed) + await submitApplications(opportunityId, tokenV3); - previousRoles.forEach((id) => { - if (!_.includes(selectedRoles, id)) { - rolesToCancel.push(id); - } - }); - - selectedRoles.forEach((id) => { - if (!_.includes(previousRoles, id)) { - rolesToApply.push(id); - } - }); + toggleApplyModal(); - if (rolesToApply.length) { - submitApplications(challengeId, rolesToApply, tokenV3); - } - if (rolesToCancel.length) { - cancelApplications(challengeId, rolesToCancel, tokenV3); + await loadDetails(challengeId, opportunityId, tokenV3); + } catch (err) { + logger.error('submitApplications failed', err); + toggleApplyModal(); } - - toggleApplyModal(); - loadDetails(challengeId, tokenV3); + loadDetails(challengeId, opportunityId, tokenV3); } render() { @@ -130,6 +133,8 @@ ReviewOpportunityDetailsContainer.defaultProps = { termsFailure: false, phasesExpanded: false, tokenV3: null, + isLoggedIn: false, + isReviewer: false, }; /** @@ -140,6 +145,7 @@ ReviewOpportunityDetailsContainer.propTypes = { authError: PT.bool, cancelApplications: PT.func.isRequired, challengeId: PT.string.isRequired, + opportunityId: PT.string.isRequired, details: PT.shape(), handle: PT.string.isRequired, isLoadingDetails: PT.bool, @@ -157,6 +163,8 @@ ReviewOpportunityDetailsContainer.propTypes = { toggleRole: PT.func.isRequired, onPhaseExpand: PT.func.isRequired, tokenV3: PT.string, + isLoggedIn: PT.bool, + isReviewer: PT.bool, }; /** @@ -169,12 +177,14 @@ ReviewOpportunityDetailsContainer.propTypes = { const mapStateToProps = (state, ownProps) => { const api = state.reviewOpportunity; const page = state.page.reviewOpportunityDetails; + const queryParams = new URLSearchParams(ownProps.location.search); const { terms } = state; return { authError: api.authError, applyModalOpened: page.applyModalOpened, challengeId: String(ownProps.match.params.challengeId), - details: api.details, + opportunityId: queryParams.get('opportunityId'), + details: page.details, handle: state.auth.user ? state.auth.user.handle : '', isLoadingDetails: api.isLoadingDetails, phasesExpanded: page.phasesExpanded, @@ -184,6 +194,8 @@ const mapStateToProps = (state, ownProps) => { terms: terms.terms, termsFailure: terms.getTermsFailure, tokenV3: state.auth.tokenV3, + isLoggedIn: Boolean(state.auth.user), + isReviewer: _.includes((state.auth.user && state.auth.user.roles) || [], 'reviewer'), }; }; @@ -201,9 +213,9 @@ function mapDispatchToProps(dispatch) { dispatch(api.cancelApplicationsInit()); dispatch(api.cancelApplicationsDone(challengeId, roleIds, tokenV3)); }, - loadDetails: (challengeId, tokenV3) => { - dispatch(api.getDetailsInit()); - dispatch(api.getDetailsDone(challengeId, tokenV3)); + loadDetails: (challengeId, opportunityId, tokenV3) => { + dispatch(page.getDetailsInit()); + return dispatch(page.getDetailsDone(challengeId, opportunityId, tokenV3)); }, onPhaseExpand: () => dispatch(page.togglePhasesExpand()), openTermsModal: () => { @@ -211,9 +223,9 @@ function mapDispatchToProps(dispatch) { }, selectTab: tab => dispatch(page.selectTab(tab)), setRoles: roles => dispatch(page.setRoles(roles)), - submitApplications: (challengeId, roleIds, tokenV3) => { - dispatch(api.submitApplicationsInit()); - dispatch(api.submitApplicationsDone(challengeId, roleIds, tokenV3)); + submitApplications: (challengeId, tokenV3) => { + dispatch(page.submitApplicationsInit()); + return dispatch(page.submitApplicationsDone(challengeId, tokenV3)); }, toggleApplyModal: () => { dispatch(page.toggleApplyModal()); diff --git a/src/shared/containers/SmartLooker.jsx b/src/shared/containers/SmartLooker.jsx new file mode 100644 index 0000000000..7770912fa0 --- /dev/null +++ b/src/shared/containers/SmartLooker.jsx @@ -0,0 +1,182 @@ +/** + * SmartLooker bridges legacy usages to the new + * reports-api-v6 endpoints for the /community/statistics page. The reports + * service now mirrors the original Looker schemas, so we simply proxy data + * from the API to the existing Looker component. + */ +import React from 'react'; +import PT from 'prop-types'; +import { config } from 'topcoder-react-utils'; +import Looker from 'components/Looker'; + +const LOOKER_TO_REPORTS_PATH = { + 1127: '/statistics/development/challenges-technology', + 1129: '/statistics/development/countries-represented', + 1130: '/statistics/development/first-place-wins', + 1131: '/statistics/development/prototype-wins', + 1132: '/statistics/development/code-wins', + 1133: '/statistics/development/f2f-wins', + 1135: '/statistics/design/first-place-by-country', + 1136: '/statistics/design/countries-represented', + 1138: '/statistics/design/ui-design-wins', + 1140: '/statistics/design/wireframe-wins', + 1141: '/statistics/design/f2f-wins', + 1146: '/statistics/general/copiloted-challenges', + 1148: '/statistics/general/countries-represented', + 1149: '/statistics/general/first-place-by-country', + 1150: '/statistics/general/reviews-by-member', + 1172: '/statistics/development/first-time-submitters', + 1178: '/statistics/design/first-time-submitters', + 1571: '/statistics/design/lux-first-place-wins', + 1572: '/statistics/design/lux-placements', + 1573: '/statistics/design/rux-first-place-wins', + 1574: '/statistics/design/rux-placements', + 1652: '/statistics/mm/top-rated', + 1653: '/statistics/srm/top-rated', + 1654: '/statistics/srm/competitions-count', + 1655: '/statistics/mm/competitions-count', + 1656: '/statistics/mm/top-10-finishes', + 1657: '/statistics/srm/country-ratings', + 1658: '/statistics/mm/country-ratings', + 1700: '/statistics/qa/wins', +}; + +function inferFromProps(property, render) { + if (property) { + const normalized = String(property).toLowerCase(); + if (normalized === 'user.count') { + return { path: '/statistics/general/member-count' }; + } + if (normalized === 'challenge.count') { + return { path: '/statistics/general/completed-challenges' }; + } + if ( + normalized === 'total' + || normalized.includes('total') + || normalized.includes('prize') + || normalized.includes('payment') + || normalized.includes('amount') + ) { + return { + path: '/statistics/general/total-prizes', + transform: data => ([{ [property]: data.total }]), + }; + } + } + + if (!property && render) { + try { + const source = String(render).replace(/&q;/g, '"').replace(/'/g, '"'); + const matches = Array.from( + source.matchAll(/data\s*\[\s*0\s*]\s*\[\s*"([^"]+)"\s*]/g), + ); + const referenced = matches.map(match => (match && match[1] ? match[1] : '')); + const hasTotalPrizes = referenced.some((ref) => { + const lower = ref.toLowerCase(); + return ( + lower.includes('total') + || lower.includes('prize') + || lower.includes('payment') + || lower.includes('amount') + ); + }); + if (hasTotalPrizes) { + const key = referenced.find(ref => ref) || 'total'; + return { + path: '/statistics/general/total-prizes', + transform: data => ([{ [key]: data.total }]), + }; + } + } catch (err) { + // swallow and fall through to unsupported handling + } + } + + return null; +} + +export default function SmartLooker(props) { + const { lookerId, property, render } = props; + const directPath = LOOKER_TO_REPORTS_PATH[lookerId]; + const inferred = React.useMemo( + () => (directPath ? null : inferFromProps(property, render)), + [directPath, property, render], + ); + const reportsPath = directPath || (inferred && inferred.path); + const transformer = inferred && inferred.transform; + + const [state, setState] = React.useState({ + loading: !!reportsPath, + error: null, + lookerInfo: null, + }); + + React.useEffect(() => { + let cancelled = false; + + async function load() { + if (!reportsPath) return; + setState(s => ({ ...s, loading: true, error: null })); + + try { + const res = await fetch(`${config.API.V6}/reports${reportsPath}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + let lookerData = Array.isArray(data) ? data : [data]; + if (typeof transformer === 'function') { + const transformed = transformer(data); + if (Array.isArray(transformed)) lookerData = transformed; + else if (transformed !== undefined && transformed !== null) { + lookerData = [transformed]; + } else { + lookerData = []; + } + } + if (!cancelled) { + setState({ + loading: false, + error: null, + lookerInfo: { lookerData }, + }); + } + } catch (err) { + if (!cancelled) { + setState({ + loading: false, + error: err.message, + lookerInfo: { lookerData: [] }, + }); + } + } + } + + load(); + return () => { + cancelled = true; + }; + }, [lookerId, reportsPath, transformer]); + + if (!reportsPath) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error(`SmartLooker: no reports mapping for Looker ID ${lookerId}`); + } + return 'Statistics report mapping missing.'; + } + + if (state.loading && !state.lookerInfo) return 'loading...'; + if (state.error) return state.error; + + return ; +} + +SmartLooker.propTypes = { + lookerId: PT.string.isRequired, + property: PT.string, + render: PT.func, +}; + +SmartLooker.defaultProps = { + property: undefined, + render: undefined, +}; diff --git a/src/shared/containers/SubmissionManagement/index.jsx b/src/shared/containers/SubmissionManagement/index.jsx index a6e962c60e..0b0676b186 100644 --- a/src/shared/containers/SubmissionManagement/index.jsx +++ b/src/shared/containers/SubmissionManagement/index.jsx @@ -16,7 +16,7 @@ import { connect } from 'react-redux'; import { Modal, PrimaryButton } from 'topcoder-react-ui-kit'; import { config } from 'topcoder-react-utils'; import { actions, services } from 'topcoder-react-lib'; -import getReviewTypes from 'services/reviewTypes'; +import getReviewSummationsService from 'services/reviewSummations'; import { getSubmissionArtifacts, downloadSubmissions } from 'services/submissions'; import style from './styles.scss'; @@ -24,7 +24,121 @@ import smpActions from '../../actions/page/submission_management'; const { getService } = services.submissions; -const { getService: getMemberService } = services.members; +const SUMMATION_TYPE_PRIORITY = { + example: 0, + provisional: 1, + final: 2, + other: 3, +}; + +const normalizeTimestamp = (value) => { + if (!value) { + return 0; + } + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const ensureMetadataObject = metadata => ((metadata && typeof metadata === 'object') ? metadata : {}); + +const deriveSummationType = (summation) => { + const metadata = ensureMetadataObject(_.get(summation, 'metadata')); + const metaStage = _.toLower(_.get(metadata, 'stage', '')); + const metaTestType = _.toLower(_.get(metadata, 'testType', '')); + const isExample = summation.isExample === true || metaTestType === 'example'; + const isProvisional = summation.isProvisional === true || metaTestType === 'provisional'; + const isFinal = summation.isFinal === true || metaStage === 'final'; + + if (isExample) { + return { key: 'example', label: 'Example' }; + } + if (isProvisional) { + return { key: 'provisional', label: 'Provisional' }; + } + if (isFinal) { + return { key: 'final', label: 'Final' }; + } + return { key: 'other', label: 'Score' }; +}; + +const mergeSubmissionWithSummations = (submission, reviewSummationsBySubmission) => { + if (!submission) { + return submission; + } + const submissionId = _.toString(submission.id || submission.submissionId); + if (!submissionId) { + return submission; + } + + const summations = reviewSummationsBySubmission[submissionId]; + if (!Array.isArray(summations) || !summations.length) { + return submission; + } + + return { + ...submission, + reviewSummations: summations, + reviewSummation: summations, + }; +}; + +const buildScoreEntries = (summations = []) => { + if (!Array.isArray(summations) || !summations.length) { + return []; + } + + const latestByType = new Map(); + + summations.forEach((summation, index) => { + if (!summation) { + return; + } + + const { key, label } = deriveSummationType(summation); + const timestampRaw = summation.reviewedDate + || summation.updatedAt + || summation.createdAt + || null; + const timestampValue = normalizeTimestamp(timestampRaw); + const reviewer = summation.updatedBy || summation.createdBy || 'System'; + const aggregateScore = _.get(summation, 'aggregateScore'); + let normalizedScore = null; + if (!_.isNil(aggregateScore) && aggregateScore !== '') { + if (Number.isFinite(aggregateScore)) { + normalizedScore = aggregateScore; + } else { + const parsedScore = Number(aggregateScore); + normalizedScore = Number.isFinite(parsedScore) ? parsedScore : aggregateScore; + } + } + + const entry = { + id: summation.id || `${summation.submissionId || 'submission'}-${key}-${index}`, + label, + reviewer: reviewer || 'System', + score: _.isNil(normalizedScore) ? null : normalizedScore, + isPassing: typeof summation.isPassing === 'boolean' ? summation.isPassing : null, + reviewedOn: timestampRaw, + orderKey: key, + orderValue: SUMMATION_TYPE_PRIORITY[key] || SUMMATION_TYPE_PRIORITY.other, + timestampValue, + }; + + const existing = latestByType.get(key); + if (!existing || entry.timestampValue > existing.timestampValue) { + latestByType.set(key, entry); + } + }); + + return Array.from(latestByType.values()) + .sort((a, b) => { + if (a.orderValue !== b.orderValue) { + return a.orderValue - b.orderValue; + } + return b.timestampValue - a.timestampValue; + }) + .map(entry => _.omit(entry, ['orderKey', 'orderValue', 'timestampValue'])); +}; const theme = { container: style.modalContainer, @@ -35,14 +149,19 @@ class SubmissionManagementPageContainer extends React.Component { constructor(props) { super(props); + this.pendingReviewSummationChallengeId = null; + this.isComponentMounted = false; + this.state = { needReload: false, initialState: true, submissions: [], + reviewSummationsBySubmission: {}, }; } componentDidMount() { + this.isComponentMounted = true; const { authTokens, challenge, @@ -60,6 +179,8 @@ class SubmissionManagementPageContainer extends React.Component { if (challengeId !== loadingSubmissionsForChallengeId) { loadMySubmissions(authTokens, challengeId); } + + this.loadReviewSummations(_.get(authTokens, 'tokenV3'), challengeId); } componentWillReceiveProps(nextProps) { @@ -73,6 +194,7 @@ class SubmissionManagementPageContainer extends React.Component { this.setState({ needReload: true }); setTimeout(() => { loadMySubmissions(authTokens, challengeId); + this.loadReviewSummations(_.get(authTokens, 'tokenV3'), challengeId); this.setState({ needReload: false }); }, 2000); } @@ -84,17 +206,32 @@ class SubmissionManagementPageContainer extends React.Component { deletionSucceed, toBeDeletedId, mySubmissions, + authTokens, + challengeId, } = this.props; const { initialState } = this.state; if (initialState && mySubmissions) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ - submissions: [...mySubmissions], + submissions: this.buildSubmissionsArray(mySubmissions), initialState: false, }); return; } + + if (challengeId !== prevProps.challengeId + || _.get(authTokens, 'tokenV3') !== _.get(prevProps.authTokens, 'tokenV3')) { + this.loadReviewSummations(_.get(authTokens, 'tokenV3'), challengeId); + } + + if (mySubmissions !== prevProps.mySubmissions && !initialState && mySubmissions) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + submissions: this.buildSubmissionsArray(mySubmissions), + }); + } + const { submissions } = this.state; if (deletionSucceed !== prevProps.deletionSucceed) { @@ -108,6 +245,101 @@ class SubmissionManagementPageContainer extends React.Component { } } + componentWillUnmount() { + this.isComponentMounted = false; + this.pendingReviewSummationChallengeId = null; + } + + buildSubmissionsArray = (source) => { + const { reviewSummationsBySubmission } = this.state; + const base = Array.isArray(source) ? source : []; + return base.map(submission => ( + mergeSubmissionWithSummations(submission, reviewSummationsBySubmission) + )); + }; + + refreshSubmissionScores = () => { + if (!this.isComponentMounted) { + return; + } + this.setState((prevState) => { + if (!Array.isArray(prevState.submissions) || !prevState.submissions.length) { + return null; + } + const updated = prevState.submissions.map(submission => ( + mergeSubmissionWithSummations(submission, prevState.reviewSummationsBySubmission) + )); + if (_.isEqual(updated, prevState.submissions)) { + return null; + } + return { submissions: updated }; + }); + }; + + loadReviewSummations = async (tokenV3, challengeId) => { + const challengeIdStr = _.toString(challengeId); + if (!this.isComponentMounted || !tokenV3 || !challengeIdStr) { + if (this.isComponentMounted) { + this.setState({ reviewSummationsBySubmission: {} }, () => { + this.refreshSubmissionScores(); + }); + } + return; + } + + this.pendingReviewSummationChallengeId = challengeIdStr; + + try { + const { data } = await getReviewSummationsService(tokenV3, challengeIdStr); + if (!this.isComponentMounted || this.pendingReviewSummationChallengeId !== challengeIdStr) { + return; + } + + const grouped = {}; + (Array.isArray(data) ? data : []).forEach((summation) => { + if (!summation) { + return; + } + const submissionId = _.toString(_.get(summation, 'submissionId') || _.get(summation, 'id')); + if (!submissionId) { + return; + } + if (!grouped[submissionId]) { + grouped[submissionId] = []; + } + grouped[submissionId].push(summation); + }); + + Object.keys(grouped).forEach((key) => { + grouped[key].sort((a, b) => ( + normalizeTimestamp(_.get(b, 'reviewedDate') || _.get(b, 'updatedAt') || _.get(b, 'createdAt')) + - normalizeTimestamp(_.get(a, 'reviewedDate') || _.get(a, 'updatedAt') || _.get(a, 'createdAt')) + )); + }); + + this.setState({ reviewSummationsBySubmission: grouped }, () => { + this.refreshSubmissionScores(); + }); + } catch (error) { + if (!this.isComponentMounted || this.pendingReviewSummationChallengeId !== challengeIdStr) { + return; + } + this.setState({ reviewSummationsBySubmission: {} }, () => { + this.refreshSubmissionScores(); + }); + } + }; + + getSubmissionScores = async (submissionId) => { + const submissionKey = _.toString(submissionId); + if (!submissionKey) { + return []; + } + const { reviewSummationsBySubmission } = this.state; + const summations = reviewSummationsBySubmission[submissionKey] || []; + return buildScoreEntries(summations); + }; + render() { const { authTokens, @@ -175,18 +407,7 @@ class SubmissionManagementPageContainer extends React.Component { }, getSubmissionArtifacts: submissionId => getSubmissionArtifacts(authTokens.tokenV3, submissionId), - getReviewTypesList: () => { - const reviewTypes = getReviewTypes(authTokens.tokenV3); - return reviewTypes; - }, - getChallengeResources: (cId) => { - const membersService = getMemberService(authTokens.tokenV3); - return membersService.getChallengeResources(cId); - }, - getSubmissionInformation: (submissionId) => { - const submissionsService = getService(authTokens.tokenV3); - return submissionsService.getSubmissionInformation(submissionId); - }, + getSubmissionScores: submissionId => this.getSubmissionScores(submissionId), onlineReviewUrl: `${config.URL.ONLINE_REVIEW}/review/actions/ViewProjectDetails?pid=${challengeId}`, challengeUrl: `${challengesUrl}/${challengeId}`, addSumissionUrl: `${config.URL.BASE}/challenges/${challengeId}/submit`, diff --git a/src/shared/containers/SubmissionPage.jsx b/src/shared/containers/SubmissionPage.jsx index 80579b39ab..88be1deed9 100644 --- a/src/shared/containers/SubmissionPage.jsx +++ b/src/shared/containers/SubmissionPage.jsx @@ -188,7 +188,7 @@ const mapStateToProps = (state, ownProps) => { challengesUrl: ownProps.challengesUrl, tokenV2: state.auth.tokenV2, tokenV3: state.auth.tokenV3, - track: details.track, + track: (details && details.track && details.track.name) ? details.track.name : details.track, challenge: state.challenge, status: details.status, isRegistered: details.isRegistered, diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index 272aebc19e..8ff11dc0ae 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -8,7 +8,12 @@ import _ from 'lodash'; import communityActions from 'actions/tc-communities'; -import { isMM as checkIsMM, isRDM as checkIsRDM } from 'utils/challenge'; +import { + isMM as checkIsMM, + isRDM as checkIsRDM, + getTrackName, + getTypeName, +} from 'utils/challenge'; import LoadingPagePlaceholder from 'components/LoadingPagePlaceholder'; import pageActions from 'actions/page'; import ChallengeHeader from 'components/challenge-detail/Header'; @@ -43,11 +48,15 @@ import { SUBTRACKS, CHALLENGE_STATUS, } from 'utils/tc'; +import { hasOpenSubmissionPhase } from 'utils/challengePhases'; import { config } from 'topcoder-react-utils'; import MetaTags from 'components/MetaTags'; -import { actions } from 'topcoder-react-lib'; +import { decodeToken } from '@topcoder-platform/tc-auth-lib'; +import { actions, services } from 'topcoder-react-lib'; import { getService } from 'services/contentful'; import { getSubmissionArtifacts as getSubmissionArtifactsService } from 'services/submissions'; +import getReviewSummationsService from 'services/reviewSummations'; +import { buildMmSubmissionData, buildStatisticsData } from 'utils/mm-review-summations'; // import { // getDisplayRecommendedChallenges, // getRecommendedTags, @@ -87,6 +96,7 @@ import './styles.scss'; /* Holds various time ranges in milliseconds. */ const MIN = 60 * 1000; const DAY = 24 * 60 * MIN; +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); /** * Given challenge details object, it returns the URL of the image to be used in @@ -97,6 +107,8 @@ const DAY = 24 * 60 * MIN; function getOgImage(challenge) { const { legacy } = challenge; const { subTrack } = legacy; + const trackName = getTrackName(challenge); + const typeName = getTypeName(challenge); if (challenge.name.startsWith('LUX -')) return ogBigPrizesChallenge; if (challenge.name.startsWith('RUX -')) return ogBigPrizesChallenge; if (challenge.prizes) { @@ -106,15 +118,15 @@ function getOgImage(challenge) { switch (subTrack) { case SUBTRACKS.FIRST_2_FINISH: - switch (challenge.track) { - case COMPETITION_TRACKS_V3.DEVELOP: return challenge.type === 'Task' ? ogDEVTask : ogFirst2FinishDEV; - case COMPETITION_TRACKS_V3.QA: return challenge.type === 'Task' ? ogQATask : ogFirst2FinishQA; + switch (trackName) { + case COMPETITION_TRACKS_V3.DEVELOP: return typeName === 'Task' ? ogDEVTask : ogFirst2FinishDEV; + case COMPETITION_TRACKS_V3.QA: return typeName === 'Task' ? ogQATask : ogFirst2FinishQA; default: return ogFirst2FinishDEV; } case SUBTRACKS.DESIGN_FIRST_2_FINISH: - switch (challenge.track) { - case COMPETITION_TRACKS_V3.DESIGN: return challenge.type === 'Task' ? ogDESIGNTask : ogFirst2FinishDESIGN; + switch (trackName) { + case COMPETITION_TRACKS_V3.DESIGN: return typeName === 'Task' ? ogDESIGNTask : ogFirst2FinishDESIGN; default: return ogUiDesign; } @@ -139,11 +151,11 @@ function getOgImage(challenge) { default: } - switch (challenge.track) { + switch (trackName) { case COMPETITION_TRACKS_V3.DEVELOP: return ogDevelopment; - case COMPETITION_TRACKS_V3.DESIGN: return challenge.type === 'Task' ? ogDESIGNTask : ogUiDesign; + case COMPETITION_TRACKS_V3.DESIGN: return typeName === 'Task' ? ogDESIGNTask : ogUiDesign; case COMPETITION_TRACKS_V3.DS: return ogDSChallenge; - case COMPETITION_TRACKS_V3.QA: return challenge.type === 'Task' ? ogQATask : ogQAChallenge; + case COMPETITION_TRACKS_V3.QA: return typeName === 'Task' ? ogQATask : ogQAChallenge; default: return ogImage; } } @@ -223,7 +235,7 @@ class ChallengeDetailPageContainer extends React.Component { loadChallengeDetails(auth, challengeId); } - fetchChallengeStatistics(auth, challengeId); + fetchChallengeStatistics(auth, challenge); if (!allCountries.length) { getAllCountries(auth.tokenV3); @@ -254,12 +266,21 @@ class ChallengeDetailPageContainer extends React.Component { onSelectorClicked, } = this.props; + const previousChallengeId = _.get(challenge, 'id'); + const nextChallengeId = _.get(nextProps.challenge, 'id'); + + if (nextChallengeId + && nextChallengeId !== previousChallengeId + && checkIsMM(nextProps.challenge)) { + nextProps.fetchChallengeStatistics(nextProps.auth, nextProps.challenge); + } + if (challenge.isLegacyChallenge && !history.location.pathname.includes(challenge.id)) { history.location.pathname = `/challenges/${challenge.id}`; // eslint-disable-line no-param-reassign history.push(history.location.pathname, history.state); } - if (!checkIsMM(challenge) && COMPETITION_TRACKS_V3.DS !== challenge.track + if (!checkIsMM(challenge) && COMPETITION_TRACKS_V3.DS !== getTrackName(challenge) && selectedTab === DETAIL_TABS.MM_DASHBOARD) { onSelectorClicked(DETAIL_TABS.DETAILS); } @@ -405,25 +426,28 @@ class ChallengeDetailPageContainer extends React.Component { const { legacy, - legacyId, status, phases, metadata, } = challenge; let { track } = legacy || {}; - if (!track) { /* eslint-disable prefer-destructuring */ track = challenge.track || ''; } + // Normalize track to a string if needed + track = getTrackName(track); const submissionsViewable = _.find(metadata, { type: 'submissionsViewable' }); const isLoggedIn = !_.isEmpty(auth.tokenV3); const { prizeSets } = challenge; let challengePrizes = []; - const placementPrizes = _.find(prizeSets, { type: 'placement' }); + const placementPrizes = _.find( + prizeSets, + prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', + ); if (placementPrizes) { challengePrizes = _.filter(placementPrizes.prizes, p => p.value > 0); } @@ -457,7 +481,7 @@ class ChallengeDetailPageContainer extends React.Component { } let winners = challenge.winners || []; - if (challenge.type !== 'Task') { + if (getTypeName(challenge) !== 'Task') { winners = winners.filter(w => !w.type || w.type === 'final'); } @@ -468,8 +492,7 @@ class ChallengeDetailPageContainer extends React.Component { } const submissionEnded = status === CHALLENGE_STATUS.COMPLETED - || (!_.some(phases, { name: 'Submission', isOpen: true }) - && !_.some(phases, { name: 'Checkpoint Submission', isOpen: true })); + || !hasOpenSubmissionPhase(phases); return (
@@ -682,17 +705,17 @@ class ChallengeDetailPageContainer extends React.Component { ) }
- {legacyId && ( - { - registerForChallenge(auth, challengeId); - }} - /> - )} + + { + registerForChallenge(auth, challengeId); + }} + /> + {showSecurityReminder && ( this.setState({ showSecurityReminder: false })} @@ -747,13 +770,13 @@ ChallengeDetailPageContainer.defaultProps = { allCountries: [], reviewTypes: [], isMenuOpened: false, - loadingMMSubmissionsForChallengeId: '', + loadingMMSubmissionsForChallengeId: null, mmSubmissions: [], mySubmissions: [], isLoadingSubmissionInformation: false, submissionInformation: null, // prizeMode: 'money-usd', - statisticsData: null, + statisticsData: [], getSubmissionArtifacts: () => {}, }; @@ -805,7 +828,10 @@ ChallengeDetailPageContainer.propTypes = { unregistering: PT.bool.isRequired, updateChallenge: PT.func.isRequired, isMenuOpened: PT.bool, - loadingMMSubmissionsForChallengeId: PT.string, + loadingMMSubmissionsForChallengeId: PT.oneOfType([ + PT.string, + PT.oneOf([null]), + ]), mmSubmissions: PT.arrayOf(PT.shape()), loadMMSubmissions: PT.func.isRequired, isLoadingSubmissionInformation: PT.bool, @@ -837,46 +863,213 @@ function mapStateToProps(state, props) { })); if (challenge.submissions) { - challenge.submissions = challenge.submissions.map(submission => ({ - ...submission, - registrant: _.find(challenge.registrants, r => (`${r.memberId}` === `${submission.memberId}`)), - })); + // Normalize submissions shape: API may return { data, meta } now. + const normalizedSubmissions = Array.isArray(challenge.submissions) + ? challenge.submissions + : (_.get(challenge, 'submissions.data') || []); + + challenge.submissions = normalizedSubmissions.map((submission) => { + const registrant = _.find( + challenge.registrants, + r => (`${r.memberId}` === `${submission.memberId}`), + ); + // Ensure legacy fields used in UI exist + const created = submission.created || submission.createdAt || null; + const updated = submission.updated || submission.updatedAt || null; + return ({ + ...submission, + created, + updated, + registrant, + }); + }); } + const loggedInUserId = _.get(auth, 'user.userId'); + const loggedInUserHandle = _.get(auth, 'user.handle'); if (!_.isEmpty(mmSubmissions)) { mmSubmissions = mmSubmissions.map((submission) => { - let registrant; - const { memberId } = submission; - let member = memberId; - if (`${auth.user.userId}` === `${memberId}`) { - mySubmissions = submission.submissions || []; - mySubmissions.forEach((mySubmission, index) => { - mySubmissions[index].id = mySubmissions.length - index; - }); + let memberId = submission.memberId || submission.submitterId; + let registrant = submission.registrant; + let memberHandle = submission.member || submission.submitterHandle || ''; + let ratingValue = null; + if (_.isFinite(submission.rating)) { + ratingValue = submission.rating; + } else if (_.isFinite(submission.submitterMaxRating)) { + ratingValue = submission.submitterMaxRating; + } + + const normalizedAttempts = Array.isArray(submission.submissions) + ? submission.submissions.map((attempt, attemptIndex) => { + const normalizedAttempt = { ...attempt }; + if (!normalizedAttempt.submissionTime) { + normalizedAttempt.submissionTime = normalizedAttempt.createdAt + || normalizedAttempt.created + || normalizedAttempt.reviewedDate + || normalizedAttempt.updatedAt + || null; + } + if (!normalizedAttempt.submissionId && normalizedAttempt.id) { + normalizedAttempt.submissionId = `${normalizedAttempt.id}`; + } + + const attemptSummations = []; + if (Array.isArray(normalizedAttempt.reviewSummations)) { + attemptSummations.push(...normalizedAttempt.reviewSummations); + } + if (Array.isArray(normalizedAttempt.reviewSummation)) { + attemptSummations.push(...normalizedAttempt.reviewSummation); + } + + let effectiveSummations = attemptSummations; + if (!effectiveSummations.length) { + const submissionLevelSummations = []; + if (Array.isArray(submission.reviewSummations)) { + submissionLevelSummations.push(...submission.reviewSummations); + } + if (Array.isArray(submission.reviewSummation)) { + submissionLevelSummations.push(...submission.reviewSummation); + } + // Fall back to submission-level scores for the latest attempt when + // per-attempt review summaries are not available. + effectiveSummations = submissionLevelSummations.length && attemptIndex === 0 + ? submissionLevelSummations + : attemptSummations; + } + + const toNumericScore = (value) => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + }; + + const findScore = (summations, predicate) => { + if (!Array.isArray(summations) || !summations.length) { + return null; + } + const match = _.find(summations, predicate); + if (!match) { + return null; + } + return toNumericScore(match.aggregateScore); + }; + + const hasProvisionalScore = !_.isNil(normalizedAttempt.provisionalScore); + const hasFinalScore = !_.isNil(normalizedAttempt.finalScore); + + if (!hasProvisionalScore) { + const provisionalScore = findScore( + effectiveSummations, + s => s && (s.isProvisional || _.get(s, 'type', '').toLowerCase() === 'provisional'), + ); + if (!_.isNil(provisionalScore)) { + normalizedAttempt.provisionalScore = provisionalScore; + } + } + + if (!hasFinalScore) { + const finalScore = findScore( + effectiveSummations, + s => s && (s.isFinal || _.get(s, 'type', '').toLowerCase() === 'final'), + ); + if (!_.isNil(finalScore)) { + normalizedAttempt.finalScore = finalScore; + } + } + if (process.env.NODE_ENV !== 'production' + && _.isNil(normalizedAttempt.provisionalScore) + && _.isNil(normalizedAttempt.finalScore) + && effectiveSummations.length) { + // eslint-disable-next-line no-console + console.warn('Submission attempt missing review scores despite summations', { + attemptId: normalizedAttempt.submissionId || normalizedAttempt.id, + }); + } + + return normalizedAttempt; + }) + : []; + + const normalizedHandle = _.toLower(memberHandle || ''); + let submissionDetail = null; + if (memberId) { + submissionDetail = _.find( + challenge.submissions, + s => (`${s.memberId}` === `${memberId}`), + ); + } + if (!submissionDetail && normalizedHandle) { + submissionDetail = _.find( + challenge.submissions, + (s) => { + const submissionHandle = _.toLower(_.get(s, 'registrant.memberHandle') + || _.get(s, 'memberHandle') + || _.get(s, 'createdBy') + || ''); + return submissionHandle && submissionHandle === normalizedHandle; + }, + ); } - const submissionDetail = _.find(challenge.submissions, s => (`${s.memberId}` === `${submission.memberId}`)); if (submissionDetail) { - member = submissionDetail.createdBy; - ({ registrant } = submissionDetail); + registrant = registrant || submissionDetail.registrant; + memberHandle = memberHandle || submissionDetail.createdBy; + if (!memberId && submissionDetail.memberId) { + memberId = `${submissionDetail.memberId}`; + } } - if (!registrant) { + if (!registrant && memberId) { registrant = _.find(challenge.registrants, r => `${r.memberId}` === `${memberId}`); } + if (!registrant && normalizedHandle) { + registrant = _.find( + challenge.registrants, + (r) => { + const registrantHandle = _.toLower(r.memberHandle || r.handle || ''); + return registrantHandle && registrantHandle === normalizedHandle; + }, + ); + } if (registrant) { - member = registrant.memberHandle; + memberHandle = memberHandle || registrant.memberHandle; + if (!_.isFinite(ratingValue) && _.isFinite(registrant.rating)) { + ratingValue = registrant.rating; + } + if (!memberId && registrant.memberId) { + memberId = `${registrant.memberId}`; + } + } + + if (!memberHandle && !_.isNil(memberId)) { + memberHandle = `${memberId}`; + } + + const isLoggedInSubmitter = ( + memberId && loggedInUserId && `${loggedInUserId}` === `${memberId}` + ) || ( + normalizedHandle + && loggedInUserHandle + && normalizedHandle === _.toLower(loggedInUserHandle) + ); + + if (isLoggedInSubmitter) { + mySubmissions = normalizedAttempts.map((attempt, index) => ({ + ...attempt, + id: normalizedAttempts.length - index, + })); } return ({ ...submission, + submissions: normalizedAttempts, registrant, - member, + member: memberHandle, + rating: ratingValue, }); }); - } else { - mySubmissions = _.filter(challenge.submissions, s => (`${s.memberId}` === `${auth.user.userId}`)); + } else if (loggedInUserId) { + mySubmissions = _.filter(challenge.submissions, s => (`${s.memberId}` === `${loggedInUserId}`)); } } const { page: { challengeDetails: { feedbackOpen } } } = state; @@ -938,6 +1131,76 @@ function mapStateToProps(state, props) { const mapDispatchToProps = (dispatch) => { const ca = communityActions.tcCommunity; const lookupActions = actions.lookup; + const challengeActions = actions.challenge || {}; + const hasReviewSummationsActions = ( + typeof challengeActions.getReviewSummationsInit === 'function' + && typeof challengeActions.getReviewSummationsDone === 'function' + ); + + const dispatchReviewSummations = (challengeId, tokenV3) => { + const challengeIdStr = _.toString(challengeId); + if (!challengeIdStr) { + return; + } + + if (hasReviewSummationsActions) { + dispatch(challengeActions.getReviewSummationsInit(challengeIdStr)); + dispatch(challengeActions.getReviewSummationsDone(challengeIdStr, tokenV3)); + return; + } + + dispatch({ + type: 'CHALLENGE/GET_REVIEW_SUMMATIONS_INIT', + payload: challengeIdStr, + }); + dispatch({ + type: 'CHALLENGE/GET_MM_SUBMISSIONS_INIT', + payload: challengeIdStr, + }); + + getReviewSummationsService(tokenV3, challengeIdStr) + .then(({ data }) => { + const reviewSummations = Array.isArray(data) ? data : []; + const mmSubmissions = buildMmSubmissionData(reviewSummations); + const statisticsData = buildStatisticsData(reviewSummations); + + dispatch({ + type: 'CHALLENGE/GET_REVIEW_SUMMATIONS_DONE', + payload: reviewSummations, + meta: { challengeId: challengeIdStr }, + }); + dispatch({ + type: 'CHALLENGE/GET_MM_SUBMISSIONS_DONE', + payload: { + challengeId: challengeIdStr, + submissions: mmSubmissions, + }, + }); + dispatch({ + type: 'CHALLENGE/FETCH_CHALLENGE_STATISTICS_DONE', + payload: statisticsData, + }); + }) + .catch((error) => { + dispatch({ + type: 'CHALLENGE/GET_REVIEW_SUMMATIONS_DONE', + error: true, + payload: { challengeId: challengeIdStr, error }, + meta: { challengeId: challengeIdStr }, + }); + dispatch({ + type: 'CHALLENGE/GET_MM_SUBMISSIONS_DONE', + error: true, + payload: { challengeId: challengeIdStr, error }, + }); + dispatch({ + type: 'CHALLENGE/FETCH_CHALLENGE_STATISTICS_DONE', + error: true, + payload: error, + }); + }); + }; + return { // getAllRecommendedChallenges: (tokenV3, recommendedTechnology) => { // const uuid = shortId(); @@ -968,7 +1231,8 @@ const mapDispatchToProps = (dispatch) => { dispatch(a.getDetailsDone(challengeId, tokens.tokenV3, tokens.tokenV2)) .then((res) => { const ch = res.payload; - if (ch.track === COMPETITION_TRACKS.DES) { + const chTrack = (ch && ch.track && ch.track.name) ? ch.track.name : ch.track; + if (chTrack === COMPETITION_TRACKS.DES) { const p = ch.phases || [] .filter(x => x.name === 'Checkpoint Review'); if (p.length && !p[0].isOpen) { @@ -986,13 +1250,66 @@ const mapDispatchToProps = (dispatch) => { registerForChallenge: (auth, challengeId) => { const a = actions.challenge; dispatch(a.registerInit()); - dispatch(a.registerDone(auth, challengeId)); + + const challengeService = services.challenge.getService(auth.tokenV3, auth.tokenV2); + const apiV6 = services.api.getApi('V6', auth.tokenV3); + const actionType = a.registerDone.toString(); + + const payload = (async () => { + try { + if (!auth.tokenV3) { + throw new Error('Authentication token is required to register.'); + } + + const roleId = await challengeService.getRoleId('Submitter'); + const user = decodeToken(auth.tokenV3); + const requestBody = { + challengeId, + memberHandle: encodeURIComponent(user.handle), + roleId, + }; + + const response = await apiV6.postJson('/resources', requestBody); + let responseData = null; + + if (!response.ok) { + try { + responseData = await response.json(); + } catch (parseError) { + responseData = null; + } + const error = new Error((responseData && responseData.message) + || response.statusText + || 'Failed to register for the challenge.'); + error.status = response.status; + if (responseData && responseData.metadata && responseData.metadata.missingTerms) { + error.missingTerms = responseData.metadata.missingTerms; + } + throw error; + } + + await response.json().catch(() => null); + await wait(config.CHALLENGE_DETAILS_REFRESH_DELAY); + return challengeService.getChallengeDetails(challengeId); + } catch (err) { + if (err.status === 403 && err.missingTerms) { + dispatch(termsActions.terms.openTermsModal('ANY')); + } + throw err; + } + })(); + + return dispatch({ + type: actionType, + payload, + }); }, reloadChallengeDetails: (tokens, challengeId) => { const a = actions.challenge; dispatch(a.getDetailsDone(challengeId, tokens.tokenV3, tokens.tokenV2)) .then((challengeDetails) => { - if (challengeDetails.track === COMPETITION_TRACKS.DES) { + const trackName = _.get(challengeDetails, 'track.name', challengeDetails.track); + if (trackName === COMPETITION_TRACKS.DES) { const p = challengeDetails.phases || [] .filter(x => x.name === 'Checkpoint Review'); if (p.length && !p[0].isOpen) { @@ -1053,9 +1370,7 @@ const mapDispatchToProps = (dispatch) => { dispatch(a.updateChallengeDone(uuid, challenge, tokenV3)); }, loadMMSubmissions: (challengeId, tokenV3) => { - const a = actions.challenge; - dispatch(a.getMmSubmissionsInit(challengeId)); - dispatch(a.getMmSubmissionsDone(challengeId, tokenV3)); + dispatchReviewSummations(challengeId, tokenV3); }, getSubmissionArtifacts: (submissionId, tokenV3) => getSubmissionArtifactsService(tokenV3, submissionId), @@ -1068,10 +1383,17 @@ const mapDispatchToProps = (dispatch) => { const a = challengeListingActions.challengeListing; dispatch(a.expandTag(id)); }, - fetchChallengeStatistics: (tokens, challengeId) => { - const a = actions.challenge; - dispatch(a.fetchChallengeStatisticsInit()); - dispatch(a.fetchChallengeStatisticsDone(challengeId, tokens.tokenV3)); + fetchChallengeStatistics: (tokens, challengeDetails) => { + if (!tokens || !tokens.tokenV3 || !challengeDetails || !checkIsMM(challengeDetails)) { + return; + } + + const challengeId = _.toString(challengeDetails.id || challengeDetails.legacyId); + if (!challengeId) { + return; + } + + dispatchReviewSummations(challengeId, tokens.tokenV3); }, }; }; diff --git a/src/shared/containers/challenge-listing/FilterPanel.jsx b/src/shared/containers/challenge-listing/FilterPanel.jsx index 36a342925f..367e70f732 100644 --- a/src/shared/containers/challenge-listing/FilterPanel.jsx +++ b/src/shared/containers/challenge-listing/FilterPanel.jsx @@ -17,10 +17,10 @@ import { connect } from 'react-redux'; import qs from 'qs'; import _ from 'lodash'; import { createStaticRanges } from 'utils/challenge-listing/date-range'; +import { EXCLUDED_CHALLENGE_TYPE_NAMES } from 'utils/challenge-listing/constants'; const MIN = 60 * 1000; - export class Container extends React.Component { constructor(props) { super(props); @@ -96,13 +96,26 @@ export class Container extends React.Component { setFilterState, validTypes, } = this.props; + const currentTypes = filterState.types || []; - if (!filterState.types.length && validTypes.length && !this.initialDefaultChallengeTypes) { + if (!currentTypes.length && validTypes.length && !this.initialDefaultChallengeTypes) { setFilterState({ ..._.clone(filterState), types: validTypes.map(item => item.abbreviation), }); this.initialDefaultChallengeTypes = true; + } else if (validTypes.length && currentTypes.length) { + const validAbbreviations = validTypes.map(item => item.abbreviation); + const sanitizedTypes = currentTypes.filter(type => validAbbreviations.includes(type)); + if (sanitizedTypes.length !== currentTypes.length) { + if (!sanitizedTypes.length) { + this.initialDefaultChallengeTypes = false; + } + setFilterState({ + ..._.clone(filterState), + types: sanitizedTypes, + }); + } } } @@ -222,6 +235,23 @@ function mapDispatchToProps(dispatch) { function mapStateToProps(state, ownProps) { const cl = state.challengeListing; const tc = state.tcCommunities; + const filteredChallengeTypes = cl.challengeTypes + .filter(type => !EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name)); + const excludedTypeAbbreviations = cl.challengeTypes + .filter(type => EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name)) + .map(type => type.abbreviation); + let filterState = cl.filter; + const existingTypes = Array.isArray(cl.filter.types) ? cl.filter.types : []; + if (excludedTypeAbbreviations.length && existingTypes.length) { + const sanitizedTypes = existingTypes + .filter(type => !excludedTypeAbbreviations.includes(type)); + if (sanitizedTypes.length !== existingTypes.length) { + filterState = { + ...cl.filter, + types: sanitizedTypes, + }; + } + } return { ...ownProps, ...state.challengeListing.filterPanel, @@ -229,9 +259,9 @@ function mapStateToProps(state, ownProps) { communityFilters: tc.list.data, communityList: tc.list, defaultCommunityId: ownProps.defaultCommunityId, - filterState: cl.filter, + filterState, loadingTypes: cl.loadingChallengeTypes, - validTypes: cl.challengeTypes, + validTypes: filteredChallengeTypes, selectedCommunityId: cl.selectedCommunityId, auth: state.auth, tokenV2: state.auth.tokenV2, diff --git a/src/shared/containers/challenge-listing/Listing/index.jsx b/src/shared/containers/challenge-listing/Listing/index.jsx index a844a84917..91dad78378 100644 --- a/src/shared/containers/challenge-listing/Listing/index.jsx +++ b/src/shared/containers/challenge-listing/Listing/index.jsx @@ -602,7 +602,7 @@ export class ListingContainer extends React.Component { let loadMoreReviewOpportunities; if (!allReviewOpportunitiesLoaded) { loadMoreReviewOpportunities = () => getReviewOpportunities( - 1 + lastRequestedPageOfReviewOpportunities, tokenV3, + 1 + lastRequestedPageOfReviewOpportunities, ); } @@ -1001,10 +1001,10 @@ function mapDispatchToProps(dispatch) { dispatch(a.getPastChallengesInit(uuid, page, frontFilter)); dispatch(a.getPastChallengesDone(uuid, page, filter, token, frontFilter)); }, - getReviewOpportunities: (page, token) => { + getReviewOpportunities: (page) => { const uuid = shortId(); dispatch(a.getReviewOpportunitiesInit(uuid, page)); - dispatch(a.getReviewOpportunitiesDone(uuid, page, token)); + dispatch(a.getReviewOpportunitiesDone(uuid, page)); }, getCopilotOpportunities: (page) => { const uuid = shortId(); diff --git a/src/shared/containers/tc-communities/cognitive/home.jsx b/src/shared/containers/tc-communities/cognitive/home.jsx index aa817e8b7a..53ebdc8222 100644 --- a/src/shared/containers/tc-communities/cognitive/home.jsx +++ b/src/shared/containers/tc-communities/cognitive/home.jsx @@ -54,7 +54,7 @@ class HomeContainer extends React.Component { if (filter) { filter = Filter.getFilterFunction(filter.challengeFilter); challenges = activeChallenges - .filter(x => x.status === 'Active') + .filter(x => x.status === 'ACTIVE') .filter(filter) .sort((a, b) => moment(a.registrationStartDate).diff(b.registrationStartDate)); } diff --git a/src/shared/reducers/challenge-listing/index.js b/src/shared/reducers/challenge-listing/index.js index 2719e90409..a35a06d046 100644 --- a/src/shared/reducers/challenge-listing/index.js +++ b/src/shared/reducers/challenge-listing/index.js @@ -15,6 +15,7 @@ import { actions as actionsUtils, } from 'topcoder-react-lib'; import { REVIEW_OPPORTUNITY_TYPES } from 'utils/tc'; +import { EXCLUDED_CHALLENGE_TYPE_NAMES } from 'utils/challenge-listing/constants'; import filterPanel from './filter-panel'; import sidebar, { factory as sidebarFactory } from './sidebar'; @@ -405,15 +406,24 @@ function onSetFilter(state, { payload }) { /* Validation of filter parameters: they may come from URL query, thus * validation is not a bad idea. As you may note, at the moment we do not * do it very carefuly (many params are not validated). */ + const basePayload = _.isPlainObject(payload) ? payload : {}; + const sanitizedPayload = { ...basePayload }; + const excludedTypeAbbreviations = state.challengeTypes + .filter(type => EXCLUDED_CHALLENGE_TYPE_NAMES.includes(type.name)) + .map(type => type.abbreviation); + if (excludedTypeAbbreviations.length && Array.isArray(basePayload.types)) { + sanitizedPayload.types = basePayload.types + .filter(type => !excludedTypeAbbreviations.includes(type)); + } const filter = _.pickBy(_.pick( - payload, + sanitizedPayload, ['tags', 'types', 'search', 'startDateEnd', 'endDateStart', 'groups', 'events', 'tracks', 'tco', 'isInnovationChallenge'], ), value => (!_.isArray(value) && value && value !== '') || (_.isArray(value) && value.length > 0)); const emptyArrayAllowedFields = ['types']; emptyArrayAllowedFields.forEach((field) => { - if (_.isEqual(payload[field], [])) { - filter[field] = payload[field]; + if (_.isEqual(sanitizedPayload[field], [])) { + filter[field] = sanitizedPayload[field]; } }); @@ -437,7 +447,7 @@ function onSetFilter(state, { payload }) { // console.log(`======`); return { ...state, - filter: _.assign({}, state.filter, payload), + filter: _.assign({}, state.filter, sanitizedPayload), /* Page numbers of past/upcoming challenges depend on the filters. To keep * the code simple we just reset them each time a filter is modified. */ @@ -482,9 +492,9 @@ function onGetReviewOpportunitiesDone(state, { payload, error }) { if (uuid !== state.loadingReviewOpportunitiesUUID) return state; const ids = new Set(); - loaded.forEach(item => ids.add(item.id)); + loaded.forEach(item => ids.add(item.challengeId)); const reviewOpportunities = state.reviewOpportunities - .filter(item => !ids.has(item.id)) + .filter(item => !ids.has(item.challengeId)) .concat(loaded); return { diff --git a/src/shared/reducers/page/review-opportunity-details.js b/src/shared/reducers/page/review-opportunity-details.js index 39b8127e44..fe9fa08a49 100644 --- a/src/shared/reducers/page/review-opportunity-details.js +++ b/src/shared/reducers/page/review-opportunity-details.js @@ -3,6 +3,52 @@ import { handleActions } from 'redux-actions'; import actions, { TABS } from 'actions/page/review-opportunity-details'; +/** + * Generates a list of unique terms ids required for the open review roles + * with an agreed field + * + * @param {Object} details Review Opportuny details from API + * @return {Array} List of unique terms + */ +function buildRequiredTermsList(details) { + const roles = details.payments.map(payment => payment.role); + + const requiredTerms = _.uniqBy( + details.challenge.terms + // Sometimes roles such as Primary Reviewer have no directly equal + // terms entry. Include the plain Reviewer terms when present as a back-up. + .filter(term => term.role === 'Reviewer' || _.includes(roles, term.role)) + .map(term => _.pick(term, ['id', 'agreed', 'title'])), + term => term.id, + ); + + return requiredTerms || []; +} + + +/** + * Handles REVIEW_OPPORTUNITY/GET__DETAILS_DONE action. + * @param {Object} state + * @param {Object} action Payload will be JSON from api call + * @return {Object} New state + */ +function onGetDetailsDone(state, { payload, error }) { + if (error) { + return { + ...state, + authError: true, + isLoadingDetails: false, + }; + } + + return { + ...state, + details: payload.details, + isLoadingDetails: false, + requiredTerms: buildRequiredTermsList(payload.details), + }; +} + /** * Creates a new reducer. * @param {Object} state Optional. Initial state. @@ -11,6 +57,8 @@ import actions, { TABS } from 'actions/page/review-opportunity-details'; function create(defaultState = {}) { const a = actions.page.reviewOpportunityDetails; return handleActions({ + [a.getDetailsInit]: state => ({ ...state, isLoadingDetails: true }), + [a.getDetailsDone]: onGetDetailsDone, [a.selectTab]: (state, { payload }) => ({ ...state, selectedTab: payload }), [a.setRoles]: (state, { payload }) => ({ ...state, selectedRoles: payload }), [a.toggleApplyModal]: state => ({ ...state, applyModalOpened: !state.applyModalOpened }), diff --git a/src/shared/routes/Communities/Veterans/Routes.jsx b/src/shared/routes/Communities/Veterans/Routes.jsx index c15f932bc7..7ebf832deb 100644 --- a/src/shared/routes/Communities/Veterans/Routes.jsx +++ b/src/shared/routes/Communities/Veterans/Routes.jsx @@ -31,7 +31,7 @@ export default function Veterans({ base, member, meta }) { registerBucket(ID, { filter: { ...meta.challengeFilter, - status: 'Active', + status: 'ACTIVE', }, hideCount: false, name: 'Active Veterans Challenges', diff --git a/src/shared/services/dashboard.js b/src/shared/services/dashboard.js index cbcd3cc60a..8fb874bc9d 100644 --- a/src/shared/services/dashboard.js +++ b/src/shared/services/dashboard.js @@ -5,12 +5,12 @@ const { getApi } = services.api; class DashboardService { /** - * @param {String} tokenV5 Optional. Auth token for Topcoder API v5. + * @param {String} tokenV6 Optional. Auth token for Topcoder API v6. */ - constructor(tokenV5) { + constructor(tokenV6) { this.private = { - api: getApi('V5', tokenV5), - tokenV5, + api: getApi('V6', tokenV6), + tokenV6, }; } @@ -27,13 +27,13 @@ class DashboardService { /** * Returns a new or existing challenges service. - * @param {String} tokenV5 Optional. Auth token for Topcoder API v5. + * @param {String} tokenV6 Optional. Auth token for Topcoder API v6. * @return {DashboardService} Dashboard service object */ let lastInstance = null; -export function getService(tokenV5) { - if (!lastInstance || tokenV5 !== lastInstance.private.tokenV5) { - lastInstance = new DashboardService(tokenV5); +export function getService(tokenV6) { + if (!lastInstance || tokenV6 !== lastInstance.private.tokenV6) { + lastInstance = new DashboardService(tokenV6); } return lastInstance; } diff --git a/src/shared/services/reviewOpportunities.js b/src/shared/services/reviewOpportunities.js new file mode 100644 index 0000000000..d9e525e654 --- /dev/null +++ b/src/shared/services/reviewOpportunities.js @@ -0,0 +1,90 @@ +import { config } from 'topcoder-react-utils'; + +const v6ApiUrl = config.API.V6; + +/** + * Fetches copilot opportunities. + * + * @param {number} page - Page number (1-based). + * @param {number} pageSize - Number of items per page. + * @returns {Promise} The fetched data. + */ +export default async function getReviewOpportunities(page, pageSize) { + const offset = page * pageSize; + + const url = new URL(`${v6ApiUrl}/review-opportunities`); + url.searchParams.append('limit', pageSize); + url.searchParams.append('offset', offset); + + const res = await fetch(url.toString(), { method: 'GET' }); + + if (!res.ok) { + throw new Error(res.statusText); + } + + return res.json(); +} + +/** + * Gets the details of the review opportunity for the corresponding challenge + * @param {Number} challengeId The ID of the challenge (not the opportunity id) + * @return {Object} The combined data of the review opportunity and challenge details + */ +export async function getDetails(challengeId, opportunityId) { + const getReviewOpportunityUrl = new URL(`${v6ApiUrl}/review-opportunities/${opportunityId}`); + const getChallengeUrl = new URL(`${v6ApiUrl}/challenges/${challengeId}`); + + try { + const [opportunityRes, challengeRes] = await Promise.all([ + fetch(getReviewOpportunityUrl.toString(), { method: 'GET' }), + fetch(getChallengeUrl.toString(), { method: 'GET' }), + ]); + + if (!opportunityRes.ok) { + throw new Error(`Failed to load review opportunity: ${opportunityRes.statusText}`); + } + if (!challengeRes.ok) { + throw new Error(`Failed to load challenge details: ${challengeRes.statusText}`); + } + + const opportunityData = await opportunityRes.json(); + const challengeData = await challengeRes.json(); + + return { + ...opportunityData.result.content, + challenge: challengeData, + }; + } catch (err) { + return Promise.reject(err); + } +} + +/** + * Submits review opportunity application for the specified challenge + * @param {Number} challengeId The ID of the challenge (not the opportunity id) + * @param {Array} roleIds List of review role IDs to apply for + * @return {Promise} Resolves to the api response in JSON. + */ +export async function submitApplications(opportunityId, tokenV3) { + const payload = { + opportunityId, + role: 'REVIEWER', + }; + try { + const res = await fetch(`${v6ApiUrl}/review-applications`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenV3}`, + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`); + } + + return res.json(); + } catch (error) { + return 'There was an error while submitting the application.'; + } +} diff --git a/src/shared/services/reviewSummations.js b/src/shared/services/reviewSummations.js new file mode 100644 index 0000000000..02ae2a40f7 --- /dev/null +++ b/src/shared/services/reviewSummations.js @@ -0,0 +1,73 @@ +import _ from 'lodash'; +import { config } from 'topcoder-react-utils'; + +const baseUrl = config.URL.REVIEW_SUMMATIONS_API_URL; +const v6ApiUrl = config.API.V6; +const DEFAULT_PER_PAGE = 500; + +async function fetchReviewSummationsPage({ + challengeId, + headers, + page, + aggregated, + meta, +}) { + const url = `${v6ApiUrl}${baseUrl}?challengeId=${encodeURIComponent(challengeId)}&perPage=${DEFAULT_PER_PAGE}&page=${page}`; + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch review summations: ${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + const data = payload.data || []; + const combined = [...aggregated, ...data]; + const latestMeta = payload.meta || meta; + + const totalPages = _.get(payload, 'meta.totalPages') + || _.get(payload, 'meta.total_pages'); + const reachedEnd = !data.length + || (totalPages && page >= totalPages) + || data.length < DEFAULT_PER_PAGE; + + if (reachedEnd) { + return { + aggregated: combined, + meta: latestMeta, + }; + } + + return fetchReviewSummationsPage({ + challengeId, + headers, + page: page + 1, + aggregated: combined, + meta: latestMeta, + }); +} + +export default async function getReviewSummations(tokenV3, challengeId) { + const headers = new Headers({ + Authorization: `Bearer ${tokenV3}`, + }); + + const { aggregated, meta } = await fetchReviewSummationsPage({ + challengeId, + headers, + page: 1, + aggregated: [], + meta: null, + }); + + return { + data: aggregated, + meta: { + ...(meta || {}), + totalItems: aggregated.length, + perPage: DEFAULT_PER_PAGE, + }, + }; +} diff --git a/src/shared/services/reviewTypes.js b/src/shared/services/reviewTypes.js index 7f83391f0a..b6d51e731a 100644 --- a/src/shared/services/reviewTypes.js +++ b/src/shared/services/reviewTypes.js @@ -1,10 +1,10 @@ import { config } from 'topcoder-react-utils'; -const baseUrl = config.URL.REVIEW_API_URL; -const v5ApiUrl = config.API.V5; +const baseUrl = config.URL.REVIEW_TYPES_API_URL; +const v6ApiUrl = config.API.V6; export default function getReviewTypes(tokenV3) { - return fetch(`${v5ApiUrl}${baseUrl}?perPage=500&page=1`, { + return fetch(`${v6ApiUrl}${baseUrl}?perPage=500&page=1`, { method: 'GET', headers: new Headers({ Authorization: `Bearer ${tokenV3}`, diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js index a6084904ce..aa6351bafe 100644 --- a/src/shared/services/submissions.js +++ b/src/shared/services/submissions.js @@ -1,6 +1,6 @@ import { config } from 'topcoder-react-utils'; -const v5ApiUrl = config.API.V5; +const v5ApiUrl = config.API.V6; export const downloadSubmissions = (tokenV3, submissionId, artifactId) => fetch(`${v5ApiUrl}/submissions/${submissionId}/artifacts/${artifactId}/download`, { method: 'GET', diff --git a/src/shared/utils/challenge-detail/helper.jsx b/src/shared/utils/challenge-detail/helper.jsx index f331c239a1..b0d7d73985 100644 --- a/src/shared/utils/challenge-detail/helper.jsx +++ b/src/shared/utils/challenge-detail/helper.jsx @@ -8,6 +8,7 @@ import { convertNow as convertMoney } from 'services/money'; import { challenge as challengeUtils } from 'topcoder-react-lib'; import { config } from 'topcoder-react-utils'; import Prize from 'components/challenge-listing/ChallengeCard/Prize'; +import { getTypeName } from 'utils/challenge'; import { BUCKETS, getBuckets } from 'utils/challenge-listing/buckets'; import { phaseEndDate } from 'utils/challenge-listing/helper'; @@ -38,9 +39,9 @@ export function getChallengeTypeAbbr(track, challengeTypes) { * @param {Object} challenge challenge info */ export function getEndDate(challenge) { - const { type } = challenge; + const type = getTypeName(challenge); let phases = challenge.phases || []; - if (type === 'First2Finish' && challenge.status === 'Completed') { + if (type === 'First2Finish' && challenge.status === 'COMPLETED') { phases = challenge.phases.filter(p => p.phaseType === 'Iterative Review' && p.phaseStatus === 'Closed'); } const endPhaseDate = Math.max(...phases.map(d => phaseEndDate(d))); @@ -159,7 +160,10 @@ export function getPrizePurseUI( * @param {Object} challenge challenge info */ export function getPrizePointsUI(challenge) { - const placementPrizes = _.find(challenge.prizeSets, { type: 'placement' }); + const placementPrizes = _.find( + challenge.prizeSets, + prizeSet => ((prizeSet && prizeSet.type) || '').toLowerCase() === 'placement', + ); if (placementPrizes) { const { prizes } = placementPrizes || []; diff --git a/src/shared/utils/challenge-detail/submission-status.js b/src/shared/utils/challenge-detail/submission-status.js new file mode 100644 index 0000000000..1094d66067 --- /dev/null +++ b/src/shared/utils/challenge-detail/submission-status.js @@ -0,0 +1,71 @@ +import _ from 'lodash'; + +function toArray(value) { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.filter(Boolean); + } + if (_.isObject(value)) { + return [value]; + } + return []; +} + +function collectReviewSummations(submission) { + if (!submission) { + return []; + } + + const combined = []; + toArray(_.get(submission, 'reviewSummations')).forEach(item => combined.push(item)); + toArray(_.get(submission, 'reviewSummation')).forEach(item => combined.push(item)); + + return combined; +} + +export function getSubmissionReviewSummations(submission) { + return collectReviewSummations(submission); +} + +export function getSubmissionStatus(submission) { + const targetIdRaw = _.get(submission, 'submissionId', _.get(submission, 'id', null)); + const targetId = _.toString(targetIdRaw || '').trim(); + const shouldMatchId = Boolean(targetId); + + const reviewSummations = collectReviewSummations(submission).filter((summation) => { + if (!summation) { + return false; + } + if (!shouldMatchId) { + return true; + } + const summationIdRaw = _.get(summation, 'submissionId', _.get(summation, 'id', null)); + const summationId = _.toString(summationIdRaw || '').trim(); + return summationId && summationId === targetId; + }); + + const hasReviewSummation = reviewSummations.length > 0; + + const isAccepted = reviewSummations.some((summation) => { + const type = _.toLower(_.toString(_.get(summation, 'type', '') || '').trim()); + const hasFlag = Boolean( + _.get(summation, 'isProvisional') + || _.get(summation, 'isFinal') + || _.get(summation, 'is_provisional') + || _.get(summation, 'is_final'), + ); + return hasFlag || type === 'provisional' || type === 'final'; + }); + + return { + hasReviewSummation, + isAccepted, + }; +} + +export default { + getSubmissionStatus, + getSubmissionReviewSummations, +}; diff --git a/src/shared/utils/challenge-listing/constants.js b/src/shared/utils/challenge-listing/constants.js new file mode 100644 index 0000000000..fbddb9fdca --- /dev/null +++ b/src/shared/utils/challenge-listing/constants.js @@ -0,0 +1,4 @@ +const EXCLUDED_CHALLENGE_TYPE_NAMES = ['Topgear Task']; + +export { EXCLUDED_CHALLENGE_TYPE_NAMES }; +export default EXCLUDED_CHALLENGE_TYPE_NAMES; diff --git a/src/shared/utils/challenge-listing/sort.js b/src/shared/utils/challenge-listing/sort.js index a26ff747c7..986d30e8c2 100644 --- a/src/shared/utils/challenge-listing/sort.js +++ b/src/shared/utils/challenge-listing/sort.js @@ -96,7 +96,7 @@ export default { order: 'asc', }, [SORTS.REVIEW_OPPORTUNITIES_TITLE_A_TO_Z]: { - func: (a, b) => a.challenge.title.localeCompare(b.challenge.title), + func: (a, b) => a.challengeName.localeCompare(b.challengeName), name: 'Title A-Z', }, [SORTS.REVIEW_OPPORTUNITIES_PAYMENT]: { diff --git a/src/shared/utils/challenge.js b/src/shared/utils/challenge.js index 7cda66df6c..1e559908ec 100644 --- a/src/shared/utils/challenge.js +++ b/src/shared/utils/challenge.js @@ -3,13 +3,40 @@ */ import _ from 'lodash'; +/** + * Safely returns the string name for a provided track value which may be + * either a string or an object like { name: 'Development' }. + * @param {Object|String} trackOrChallenge Either a track object/string or a challenge object + * @returns {String} + */ +export function getTrackName(trackOrChallenge) { + const value = (trackOrChallenge && trackOrChallenge.track !== undefined) + ? trackOrChallenge.track : trackOrChallenge; + if (_.isObject(value)) return value.name || ''; + return value || ''; +} + +/** + * Safely returns the string name for a provided type value which may be + * either a string or an object like { name: 'Task' }. + * @param {Object|String} typeOrChallenge Either a type object/string or a challenge object + * @returns {String} + */ +export function getTypeName(typeOrChallenge) { + const value = (typeOrChallenge && typeOrChallenge.type !== undefined) + ? typeOrChallenge.type : typeOrChallenge; + if (_.isObject(value)) return value.name || ''; + return value || ''; +} + /** * check if is marathon match challenge * @param {Object} challenge challenge object */ export function isMM(challenge) { const tags = _.get(challenge, 'tags') || []; - const isMMType = challenge ? challenge.type === 'Marathon Match' : false; + const typeName = getTypeName(challenge); + const isMMType = challenge ? typeName === 'Marathon Match' : false; return tags.includes('Marathon Match') || isMMType; } @@ -19,7 +46,8 @@ export function isMM(challenge) { */ export function isRDM(challenge) { const tags = _.get(challenge, 'tags') || []; - const isMMType = challenge ? challenge.type === 'Rapid Development Match' : false; + const typeName = getTypeName(challenge); + const isMMType = challenge ? typeName === 'Rapid Development Match' : false; return tags.includes('Rapid Development Match') || tags.includes('RDM') || isMMType; } @@ -40,4 +68,6 @@ export function updateChallengeType(challenges, challengeTypeMap) { export default { isMM, updateChallengeType, + getTrackName, + getTypeName, }; diff --git a/src/shared/utils/challengePhases.js b/src/shared/utils/challengePhases.js new file mode 100644 index 0000000000..7773d5c7ef --- /dev/null +++ b/src/shared/utils/challengePhases.js @@ -0,0 +1,10 @@ +export const SUBMISSION_PHASE_NAMES = [ + 'Submission', + 'Checkpoint Submission', + 'Topgear Submission', +]; + +export function hasOpenSubmissionPhase(phases) { + if (!Array.isArray(phases)) return false; + return phases.some(phase => SUBMISSION_PHASE_NAMES.includes(phase.name) && phase.isOpen); +} diff --git a/src/shared/utils/markdown.js b/src/shared/utils/markdown.js index bc24c03989..7791519842 100644 --- a/src/shared/utils/markdown.js +++ b/src/shared/utils/markdown.js @@ -20,7 +20,7 @@ import 'highlight.js/styles/github.css'; import JoinCommunity from 'containers/tc-communities/JoinCommunity'; import VideoModalButton from 'components/VideoModalButton'; -import Looker from 'containers/Looker'; +import Looker from 'containers/SmartLooker'; import AnchorLink from 'react-anchor-link-smooth-scroll'; import Modal from 'components/Contentful/Modal'; import MMLeaderboard from 'containers/MMLeaderboard'; diff --git a/src/shared/utils/mm-review-summations.js b/src/shared/utils/mm-review-summations.js new file mode 100644 index 0000000000..29ce2f73de --- /dev/null +++ b/src/shared/utils/mm-review-summations.js @@ -0,0 +1,459 @@ +import _ from 'lodash'; + +function normalizeScoreValue(score) { + if (_.isNil(score) || score === '' || score === '-') { + return null; + } + const parsed = Number(score); + if (_.isNaN(parsed)) { + return null; + } + return parsed; +} + +function getSummationTimestamp(summation) { + const candidates = [ + _.get(summation, 'createdAt'), + _.get(summation, 'created'), + _.get(summation, 'reviewedDate'), + _.get(summation, 'updatedAt'), + ]; + return _.find(candidates, value => !!value) || null; +} + +function toTimestampValue(value) { + if (!value) { + return 0; + } + const timestamp = new Date(value).getTime(); + if (_.isNaN(timestamp)) { + return 0; + } + return timestamp; +} + +function getSummationHandle(summation) { + const handle = _.get(summation, 'submitterHandle'); + if (!handle || !_.isString(handle) || !handle.trim()) { + return 'unknown'; + } + return handle; +} + +function getSummationMemberId(summation) { + const memberId = _.get(summation, 'submitterId'); + return _.isNil(memberId) ? null : _.toString(memberId); +} + +function getSummationRating(summation) { + const rating = _.get(summation, 'submitterMaxRating'); + return _.isNil(rating) ? null : rating; +} + +function ensureSubmissionEntry(existingEntry, { submissionId, timestamp, timestampValue }) { + const baseEntry = { + submissionId, + submissionTime: timestamp || null, + provisionalScore: null, + finalScore: null, + status: 'completed', + reviewSummations: [], + reviewSummation: [], + latestTimestamp: timestampValue, + provisionalMeta: { timestamp: -Infinity, score: null }, + finalMeta: { timestamp: -Infinity, score: null }, + }; + + if (!existingEntry) { + return baseEntry; + } + + const reviewSummations = Array.isArray(existingEntry.reviewSummations) + ? [...existingEntry.reviewSummations] + : []; + const reviewSummation = Array.isArray(existingEntry.reviewSummation) + ? [...existingEntry.reviewSummation] + : [...reviewSummations]; + + return { + ...baseEntry, + ...existingEntry, + submissionId: existingEntry.submissionId || submissionId, + submissionTime: existingEntry.submissionTime || baseEntry.submissionTime, + status: existingEntry.status || 'completed', + reviewSummations, + reviewSummation, + latestTimestamp: _.isFinite(existingEntry.latestTimestamp) + ? existingEntry.latestTimestamp + : baseEntry.latestTimestamp, + provisionalMeta: existingEntry.provisionalMeta + ? { ...existingEntry.provisionalMeta } + : baseEntry.provisionalMeta, + finalMeta: existingEntry.finalMeta + ? { ...existingEntry.finalMeta } + : baseEntry.finalMeta, + }; +} + +function mergeScoreData(meta, currentValue, score, timestampValue, options = {}) { + const { allowOlderTimestampUpdate = true } = options; + const nextMeta = { ...meta }; + let nextValue = currentValue; + + if (timestampValue > nextMeta.timestamp) { + nextMeta.timestamp = timestampValue; + nextMeta.score = score; + nextValue = _.isNil(score) ? null : score; + } else if (timestampValue === nextMeta.timestamp) { + if (!_.isNil(score) && (_.isNil(nextMeta.score) || score > nextMeta.score)) { + nextMeta.score = score; + nextValue = score; + } + } else if (allowOlderTimestampUpdate && _.isNil(nextValue) && !_.isNil(score)) { + nextMeta.timestamp = timestampValue; + nextMeta.score = score; + nextValue = score; + } + + return { + meta: nextMeta, + value: nextValue, + }; +} + +function updateSubmissionEntry(existingEntry, { + submissionId, + timestamp, + timestampValue, + normalizedScore, + summation, + isProvisional, +}) { + const baseEntry = ensureSubmissionEntry(existingEntry, { + submissionId, + timestamp, + timestampValue, + }); + const { + submissionTime: baseSubmissionTime, + latestTimestamp: baseLatestTimestamp, + } = baseEntry; + + let submissionTime = baseSubmissionTime; + let latestTimestamp = baseLatestTimestamp; + + if (timestampValue > latestTimestamp) { + latestTimestamp = timestampValue; + submissionTime = timestamp || submissionTime; + } else if (!submissionTime && timestamp) { + submissionTime = timestamp; + } + + const provisionalResult = isProvisional + ? mergeScoreData( + baseEntry.provisionalMeta, + baseEntry.provisionalScore, + normalizedScore, + timestampValue, + ) + : { meta: baseEntry.provisionalMeta, value: baseEntry.provisionalScore }; + + const finalResult = isProvisional + ? { meta: baseEntry.finalMeta, value: baseEntry.finalScore } + : mergeScoreData( + baseEntry.finalMeta, + baseEntry.finalScore, + normalizedScore, + timestampValue, + ); + + const reviewSummations = [...baseEntry.reviewSummations, summation]; + + return { + ...baseEntry, + submissionTime, + latestTimestamp, + provisionalMeta: provisionalResult.meta, + provisionalScore: provisionalResult.value, + finalMeta: finalResult.meta, + finalScore: finalResult.value, + reviewSummations, + reviewSummation: [...reviewSummations], + }; +} + +function assignRanks(members, scoreKey, rankKey) { + const rankedEntries = members + .map(member => ({ + key: `${member.memberId || member.member || ''}`, + score: member[scoreKey], + })) + .filter(entry => !_.isNil(entry.score)) + .sort((a, b) => b.score - a.score); + + let processed = 0; + let previousScore = null; + let currentRank = 0; + const rankByKey = new Map(); + + rankedEntries.forEach((entry) => { + processed += 1; + if (previousScore === null || entry.score !== previousScore) { + currentRank = processed; + previousScore = entry.score; + } + rankByKey.set(entry.key, currentRank); + }); + + return members.map((member) => { + const key = `${member.memberId || member.member || ''}`; + const rank = rankByKey.get(key); + return { + ...member, + [rankKey]: _.isNil(rank) ? null : rank, + }; + }); +} + +function createStatisticsSubmission({ + submissionId, + timestamp, + timestampValue, + score, +}) { + return { + submissionId, + created: timestamp || null, + createdAt: timestamp || null, + score, + meta: { + timestamp: timestampValue, + score, + }, + }; +} + +function updateStatisticsSubmission(submission, { + timestamp, + timestampValue, + score, +}) { + const base = { + ...submission, + meta: submission.meta ? { ...submission.meta } : { timestamp: -Infinity, score: null }, + }; + + const previousMeta = base.meta; + const { meta, value } = mergeScoreData(previousMeta, base.score, score, timestampValue, { + allowOlderTimestampUpdate: false, + }); + + const hasNewerTimestamp = meta.timestamp > previousMeta.timestamp; + + return { + ...base, + created: hasNewerTimestamp ? (timestamp || base.created) : base.created, + createdAt: hasNewerTimestamp ? (timestamp || base.createdAt) : base.createdAt, + score: value, + meta, + }; +} + +export function buildMmSubmissionData(reviewSummations = []) { + if (!Array.isArray(reviewSummations) || !reviewSummations.length) { + return []; + } + + const membersByHandle = new Map(); + + reviewSummations.forEach((summation, index) => { + if (!summation) { + return; + } + + const handle = getSummationHandle(summation); + if (!membersByHandle.has(handle)) { + membersByHandle.set(handle, { + handle, + memberId: null, + rating: null, + submissionsMap: new Map(), + bestProvisionalScore: null, + bestFinalScore: null, + }); + } + + const memberEntry = membersByHandle.get(handle); + + const memberId = getSummationMemberId(summation); + if (!memberEntry.memberId && memberId) { + memberEntry.memberId = memberId; + } + + const rating = getSummationRating(summation); + if (_.isNil(memberEntry.rating) && !_.isNil(rating)) { + memberEntry.rating = rating; + } + + const rawSubmissionId = _.get(summation, 'submissionId', _.get(summation, 'id')); + const submissionId = rawSubmissionId ? _.toString(rawSubmissionId) : `unknown-${handle}-${index}`; + const timestamp = getSummationTimestamp(summation); + const timestampValue = toTimestampValue(timestamp); + + const normalizedScore = normalizeScoreValue(_.get(summation, 'aggregateScore')); + const isProvisional = Boolean(summation.isProvisional); + + const updatedEntry = updateSubmissionEntry(memberEntry.submissionsMap.get(submissionId), { + submissionId, + timestamp, + timestampValue, + normalizedScore, + summation, + isProvisional, + }); + + memberEntry.submissionsMap.set(submissionId, updatedEntry); + + if (!_.isNil(normalizedScore)) { + if (isProvisional) { + memberEntry.bestProvisionalScore = _.isNil(memberEntry.bestProvisionalScore) + ? normalizedScore + : Math.max(memberEntry.bestProvisionalScore, normalizedScore); + } else { + memberEntry.bestFinalScore = _.isNil(memberEntry.bestFinalScore) + ? normalizedScore + : Math.max(memberEntry.bestFinalScore, normalizedScore); + } + } + }); + + const members = Array.from(membersByHandle.values()).map((memberEntry) => { + const submissions = Array.from(memberEntry.submissionsMap.values()) + .map(submission => ({ + submissionId: submission.submissionId, + submissionTime: submission.submissionTime, + provisionalScore: _.isNil(submission.provisionalScore) ? null : submission.provisionalScore, + finalScore: _.isNil(submission.finalScore) ? null : submission.finalScore, + status: submission.status || 'completed', + reviewSummations: [...submission.reviewSummations], + reviewSummation: [...submission.reviewSummations], + })) + .sort((a, b) => toTimestampValue(b.submissionTime) - toTimestampValue(a.submissionTime)); + + const rating = _.isNil(memberEntry.rating) ? null : memberEntry.rating; + const memberId = memberEntry.memberId ? _.toString(memberEntry.memberId) : null; + + const registrant = memberId ? { + userId: memberId, + memberHandle: memberEntry.handle === 'unknown' ? null : memberEntry.handle, + rating, + } : null; + + return { + member: memberEntry.handle, + memberId, + registrant, + rating, + provisionalRank: null, + finalRank: null, + submissions, + bestProvisionalScore: memberEntry.bestProvisionalScore, + bestFinalScore: memberEntry.bestFinalScore, + }; + }); + + const withProvisionalRanks = assignRanks(members, 'bestProvisionalScore', 'provisionalRank'); + const withFinalRanks = assignRanks(withProvisionalRanks, 'bestFinalScore', 'finalRank'); + + return withFinalRanks + .map(({ bestProvisionalScore, bestFinalScore, ...rest }) => rest) + .sort((a, b) => { + if (!_.isNil(a.finalRank) && !_.isNil(b.finalRank)) { + return a.finalRank - b.finalRank; + } + if (!_.isNil(a.finalRank)) { + return -1; + } + if (!_.isNil(b.finalRank)) { + return 1; + } + if (!_.isNil(a.provisionalRank) && !_.isNil(b.provisionalRank)) { + return a.provisionalRank - b.provisionalRank; + } + if (!_.isNil(a.provisionalRank)) { + return -1; + } + if (!_.isNil(b.provisionalRank)) { + return 1; + } + return 0; + }); +} + +export function buildStatisticsData(reviewSummations = []) { + if (!Array.isArray(reviewSummations) || !reviewSummations.length) { + return []; + } + + const grouped = new Map(); + + reviewSummations.forEach((summation, index) => { + if (!summation) { + return; + } + + const handle = getSummationHandle(summation); + if (!grouped.has(handle)) { + grouped.set(handle, { + handle, + rating: null, + submissionsMap: new Map(), + }); + } + + const entry = grouped.get(handle); + + const rating = getSummationRating(summation); + if (_.isNil(entry.rating) && !_.isNil(rating)) { + entry.rating = rating; + } + + const timestamp = getSummationTimestamp(summation); + const timestampValue = toTimestampValue(timestamp); + const score = normalizeScoreValue(_.get(summation, 'aggregateScore')); + + const rawSubmissionId = _.get(summation, 'submissionId', _.get(summation, 'id')); + const submissionId = rawSubmissionId ? _.toString(rawSubmissionId) : `unknown-${handle}-${index}`; + + const existingSubmission = entry.submissionsMap.get(submissionId); + + const updatedSubmission = existingSubmission + ? updateStatisticsSubmission(existingSubmission, { timestamp, timestampValue, score }) + : createStatisticsSubmission({ + submissionId, + timestamp, + timestampValue, + score, + }); + + entry.submissionsMap.set(submissionId, updatedSubmission); + }); + + return Array.from(grouped.values()).map(entry => ({ + handle: entry.handle, + rating: entry.rating, + submissions: Array.from(entry.submissionsMap.values()) + .map(submission => ({ + submissionId: submission.submissionId, + created: submission.created, + createdAt: submission.createdAt, + score: submission.score, + })) + .sort((a, b) => toTimestampValue(b.createdAt) - toTimestampValue(a.createdAt)), + })); +} + +export default { + buildMmSubmissionData, + buildStatisticsData, +}; diff --git a/src/shared/utils/reviewOpportunities.js b/src/shared/utils/reviewOpportunities.js index 206f23bc88..95a820d481 100644 --- a/src/shared/utils/reviewOpportunities.js +++ b/src/shared/utils/reviewOpportunities.js @@ -2,6 +2,8 @@ * Utility functions for Review Opportunities */ import _ from 'lodash'; +import moment from 'moment'; +import { REVIEW_OPPORTUNITY_TYPES } from './tc'; /** * Infers open positions using review opportunity details and organizes them by role @@ -45,8 +47,106 @@ export const openPositionsByRole = (details) => { export const activeRoleIds = (details, handle) => { const positions = openPositionsByRole(details); const apps = details.applications - ? details.applications.filter(app => _.toString(app.handle) === _.toString(handle) && app.status !== 'Cancelled') : []; + ? details.applications.filter(app => _.toString(app.handle) === _.toString(handle) && app.status !== 'CANCELLED') : []; return apps.map(app => positions.find(p => p.role === app.role).roleId); }; export default null; + +/** + * Filter function for Review Opportunity Type, will be used internally in filter.js + * @param {Object} opp Review Opportunity object + * @param {Object} state Filter state + * @return {Boolean} True if opp satifies the filter + */ +function filterByReviewOpportunityType(opp, state) { + if (state.reviewOpportunityTypes.length === 0) return false; + return state.reviewOpportunityTypes.includes(opp.type); +} + +function filterByStartDate(challenge, state) { + if (!state.startDate) return true; + const submissionPhase = (challenge.phases || []).filter(d => d.name === 'Submission')[0]; + const submissionEndDate = submissionPhase ? submissionPhase.scheduledEndDate + : challenge.submissionEndDate; + return moment(state.startDate).isBefore(submissionEndDate); +} + +function filterByEndDate(challenge, state) { + if (!state.endDate) return true; + const registrationPhase = (challenge.phases || []).filter(d => d.name === 'Registration')[0]; + const registrationStartDate = registrationPhase ? registrationPhase.scheduledStartDate + : challenge.registrationStartDate; + return moment(state.endDate).isAfter(registrationStartDate); +} + +function filterByTags(challenge, state) { + if (_.isEmpty(state.tags)) return true; + const { platforms, tags } = challenge; + const str = `${platforms.join(' ')} ${tags.join(' ')}`.toLowerCase(); + return state.tags.some(tag => str.includes(tag.toLowerCase())); +} + +function filterByText(challenge, state) { + if (!state.search) return true; + const str = `${challenge.name} ${challenge.tags} ${challenge.platforms} ${challenge.tags}` + .toLowerCase(); + return str.includes(state.search.toLowerCase()); +} + +function filterByTrack(challenge, state) { + // if (!state.tracks) return true; + // eslint-disable-next-line max-len + return state.tracks[challenge.track] === true; +} + +function filterByTypes(challenge, state) { + if (state.types.length === 0) return true; + return state.types.includes(challenge.typeId); +} + +/** + * Generates a Review Opportunities filter function for the provided filter state. + * @param {Object} state + * @return {Function} + */ +export const getReviewOpportunitiesFilterFunction = (state, validTypes) => (opp) => { + const trackAbbr = { + 'Data Science': 'DS', + Development: 'Dev', + Design: 'Des', + 'Quality Assurance': 'QA', + }; + + const { challengeData } = opp; + + // const newType = _.find(validTypes, { name: opp.challenge.type }) || {}; + const newType = _.find(validTypes, { name: challengeData.subTrack === 'FIRST_2_FINISH' ? 'First2Finish' : 'Challenge' }) || {}; + + // Review Opportunity objects have a challenge field which + // is largely compatible with many of the existing filter functions + // especially after a few normalization tweaks + const challenge = { + ...challengeData, + // This allows filterByText to search for Review Types and Challenge Titles + name: `${challengeData.title} ${REVIEW_OPPORTUNITY_TYPES[opp.type]}`, + // registrationStartDate: opp.startDate, // startDate of Review, not Challenge + // submissionEndDate: opp.startDate, // Currently uses startDate for both date comparisons + // communities: new Set([ // Used to filter by Track, and communities at a future date + // opp.challenge.track === 'QA' ? 'Dev' : trackAbbr[opp.challenge.track], + // ]), + track: trackAbbr[challengeData.track], + typeId: newType.abbreviation, + tags: challengeData.technologies || [], + platforms: challengeData.platforms || [], + }; + return ( + filterByTrack(challenge, state) + && filterByText(challenge, state) + && filterByTags(challenge, state) + && filterByTypes(challenge, state) + && filterByEndDate(challenge, state) + && filterByStartDate(challenge, state) + && filterByReviewOpportunityType(opp, state) + ); +}; diff --git a/src/shared/utils/submissions.js b/src/shared/utils/submissions.js index 27cd62e800..548b24022f 100644 --- a/src/shared/utils/submissions.js +++ b/src/shared/utils/submissions.js @@ -8,7 +8,9 @@ */ export function getMMSubmissionId(submissions, handle) { const filterSubmissions = handle ? submissions.filter(s => s.createdBy === handle) : submissions; - const sortedSubmissions = filterSubmissions.sort((a, b) => (a.created < b.created ? 1 : -1)); + const sortedSubmissions = filterSubmissions.sort((a, b) => ( + (a.created || a.createdAt) < (b.created || b.createdAt) ? 1 : -1 + )); return sortedSubmissions.length > 0 ? sortedSubmissions[0].id : null; } diff --git a/src/shared/utils/tc.js b/src/shared/utils/tc.js index 27d2fd8368..37cf139e44 100644 --- a/src/shared/utils/tc.js +++ b/src/shared/utils/tc.js @@ -13,11 +13,24 @@ const { api } = services; export const { OLD_COMPETITION_TRACKS, - COMPETITION_TRACKS, CHALLENGE_STATUS, - REVIEW_OPPORTUNITY_TYPES, } = tc; +export const REVIEW_OPPORTUNITY_TYPES = { + REGULAR_REVIEW: 'Review', + COMPONENT_DEV_REVIEW: 'Component Dev Review', + SPEC_REVIEW: 'Specification Review', + ITERATIVE_REVIEW: 'Iterative Review', + SCENARIOS_REVIEW: 'Scenarios Review', +}; + +export const COMPETITION_TRACKS = { + DS: 'DATA_SCIENCE', + DES: 'DESIGN', + DEV: 'DEVELOPMENT', + QA: 'QUALITY_ASSURANCE', +}; + /** * Possible phase types (at the moment, this map does not cover all * possibilities).