diff --git a/.circleci/config.yml b/.circleci/config.yml index 21fcb93..4be26f9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,6 +77,7 @@ workflows: branches: only: - dev + - submission-page # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/dev.js b/config/dev.js index 2579050..5ca5059 100644 --- a/config/dev.js +++ b/config/dev.js @@ -12,51 +12,36 @@ module.exports = { /** * URL of Topcoder Connect Website */ - SERVER_API_KEY: "79b2d5eb-c1fd-42c4-9391-6b2c9780d591", CONNECT_WEBSITE_URL: "https://connect.topcoder-dev.com", + SERVER_API_KEY: "79b2d5eb-c1fd-42c4-9391-6b2c9780d591", URL: { - ARENA: "https://arena.topcoder-dev.com", + /* Connector URL of the TC accounts App. */ + ACCOUNTS_APP_CONNECTOR: "https://accounts-auth0.topcoder-dev.com", + + /* The remote address where the app is deployed. */ APP: "https://community-app.topcoder-dev.com", /* This is the same value as above, but it is used by topcoder-react-lib, * as a more verbose name for the param. */ COMMUNITY_APP: "https://community-app.topcoder-dev.com", + PLATFORM_WEBSITE: "https://platform.topcoder-dev.com", + ARENA: "https://arena.topcoder-dev.com", AUTH: "https://accounts-auth0.topcoder-dev.com", BASE: "https://www.topcoder-dev.com", HOME: "/my-dashboard", BLOG: "https://www.topcoder-dev.com/blog", BLOG_FEED: "https://www.topcoder.com/blog/feed/", - COMMUNITY: "https://community.topcoder-dev.com", FORUMS: "https://apps.topcoder-dev.com/forums", FORUMS_VANILLA: "https://vanilla.topcoder-dev.com", HELP: - "https://www.topcoder.com/thrive/tracks?track=Topcoder&tax=Help%20Articles", + "https://www.topcoder.com/thrive/tracks?track=Topcoder&tax=Help%20Articles", SUBMISSION_REVIEW: "https://submission-review.topcoder-dev.com", THRIVE: "https://www.topcoder.com/thrive", - MEMBER: "https://members.topcoder-dev.com", - ONLINE_REVIEW: "https://software.topcoder-dev.com", - PAYMENT_TOOL: "https://payment.topcoder-dev.com", - STUDIO: "https://studio.topcoder-dev.com", - IOS: "https://ios.topcoder-dev.com", - - /* Connector URL of the TC accounts App. */ - ACCOUNTS_APP_CONNECTOR: "https://accounts-auth0.topcoder-dev.com", - - TCO: "https://www.topcoder.com/tco", - TCO17: "https://tco17.topcoder.com/", - TCO19: "https://community-app.topcoder-dev.com/__community__/tco19", - WIPRO: "https://wipro.topcoder.com", - - TOPGEAR: "https://dev-topgear.wipro.com", - - COMMUNITY_API: "http://localhost:8000", - COMMUNITY_APP_GITHUB_ISSUES: - "https://github.com/topcoder-platform/community-app/issues", COMMUNITIES: { BLOCKCHAIN: "https://blockchain.topcoder-dev.com", COGNITIVE: "https://cognitive.topcoder-dev.com", @@ -65,6 +50,8 @@ module.exports = { CS: "https://community-app.topcoder-dev.com/__community__/cs", }, + /* Dedicated section to group together links to various articles in + * Topcoder help center. */ INFO: { DESIGN_CHALLENGES: "http://help.topcoder.com/hc/en-us/categories/202610437-DESIGN", @@ -90,10 +77,28 @@ module.exports = { TEMPLATES_REPO: "https://github.com/topcoder-platform-templates", }, + IOS: "https://ios.topcoder-dev.com", + MEMBER: "https://members.topcoder-dev.com", + ONLINE_REVIEW: "https://software.topcoder-dev.com", + PAYMENT_TOOL: "https://payment.topcoder-dev.com", + STUDIO: "https://studio.topcoder-dev.com", + TCO: "https://www.topcoder.com/tco", + TCO17: "https://tco17.topcoder.com/", + TCO19: "https://community-app.topcoder-dev.com/__community__/tco19", + + TOPGEAR: "https://dev-topgear.wipro.com", + USER_SETTINGS: "https://lc1-user-settings-service.herokuapp.com", - EMAIL_VERIFY_URL: "http://www.topcoder-dev.com/settings/account/changeEmail", + WIPRO: "https://wipro.topcoder.com", + COMMUNITY_API: "http://localhost:8000", + COMMUNITY_APP_GITHUB_ISSUES: + "https://github.com/topcoder-platform/community-app/issues", + EMAIL_VERIFY_URL: + "http://www.topcoder-dev.com/settings/account/changeEmail", ABANDONMENT_EMBED: "https://43d132d5dbff47c59d9d53ad448f93c2.js.ubembed.com", + // 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", @@ -115,7 +120,6 @@ module.exports = { V3: "https://api.topcoder-dev.com/v3", V2: "https://api.topcoder-dev.com/v2", }, - MOCK_TERMS_SERVICE: false, AV_SCAN_SCORER_REVIEW_TYPE_ID: "55bbb17d-aac2-45a6-89c3-a8d102863d05", PROVISIONAL_SCORING_COMPLETED_REVIEW_TYPE_ID: "df51ca7d-fb0a-4147-9569-992fcf5aae48", @@ -126,4 +130,13 @@ module.exports = { DEFAULT_SPACE_NAME: "default", DEFAULT_ENVIRONMENT: "master", }, + /* Filestack configuration for uploading Submissions + * These are for the development back end */ + FILESTACK: { + API_KEY: process.env.FILESTACK_API_KEY || "AzFINuQoqTmqw0QEoaw9az", + REGION: "us-east-1", + SUBMISSION_CONTAINER: + process.env.FILESTACK_SUBMISSION_CONTAINER || + "topcoder-dev-submissions-dmz", + }, }; diff --git a/config/prod.js b/config/prod.js index e198be3..ab93169 100644 --- a/config/prod.js +++ b/config/prod.js @@ -23,6 +23,7 @@ module.exports = { * as a more verbose name for the param. */ COMMUNITY_APP: "https://community-app.topcoder.com", + PLATFORM_WEBSITE: "https://platform.topcoder.com", AUTH: "https://accounts-auth0.topcoder.com", BASE: "https://www.topcoder.com", HOME: "/my-dashboard", @@ -126,4 +127,11 @@ module.exports = { DEFAULT_SPACE_NAME: "default", DEFAULT_ENVIRONMENT: "master", }, + FILESTACK: { + API_KEY: process.env.FILESTACK_API_KEY || "AzFINuQoqTmqw0QEoaw9az", + REGION: "us-east-1", + SUBMISSION_CONTAINER: + process.env.FILESTACK_SUBMISSION_CONTAINER || + "topcoder-dev-submissions-dmz", + }, }; diff --git a/package-lock.json b/package-lock.json index db26b83..d8db2c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4968,8 +4968,7 @@ "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" }, "compose-function": { "version": "3.0.3", @@ -5145,6 +5144,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -7153,6 +7157,11 @@ } } }, + "file-type": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", + "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==" + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -7166,6 +7175,67 @@ "integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==", "dev": true }, + "filestack-js": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/filestack-js/-/filestack-js-1.14.6.tgz", + "integrity": "sha512-mcME182eOUy3OyU0F9rcATQf3/YY3N1suXYVv3hcS1RxeVHIIkM9XI6N9Qg5t04y0qOGud9xv/GO+oKhreCSIw==", + "requires": { + "abab": "^2.0.0", + "ajv": "^6.5.5", + "file-type": "^8.1.0", + "filestack-loader": "^3.0.4", + "is-svg": "^3.0.0", + "isutf8": "^2.0.2", + "spark-md5": "^3.0.0", + "superagent": "^3.8.3", + "tcomb-validation": "^3.4.1", + "tslib": "^1.9.3" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "filestack-loader": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/filestack-loader/-/filestack-loader-3.0.4.tgz", + "integrity": "sha512-b6uOCWHd1gM0+5KBA1rA4qfEgTqyTr5umLM4bBWT4z98WUwxa6KzCiq+z0VnR4rN+NCx6kyZ/wLXjGcPU32TxQ==" + }, + "filestack-react": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/filestack-react/-/filestack-react-2.0.6.tgz", + "integrity": "sha512-G0IEYz+S9opbFP5duN2w6kR2A26eqNbOb4XC4UkNCNl2594Sf/rH4rRbtHnU2UQeEGR6H7/mEv1z93FYSyNyKQ==", + "requires": { + "filestack-js": "^2.1.0" + }, + "dependencies": { + "filestack-js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/filestack-js/-/filestack-js-2.1.0.tgz", + "integrity": "sha512-G8ecRKnbVBch+ycugTKPxQTyEkBQcSgh5k0DCfCF0nxyasEvimCtyTxcVC63qnPB91mZfN34vGJbixVFrfzNsQ==", + "requires": { + "abab": "^2.0.0", + "file-type": "^8.1.0", + "filestack-loader": "^3.0.4", + "is-svg": "^3.0.0", + "isutf8": "^2.0.2", + "jsonschema": "^1.2.4", + "spark-md5": "^3.0.0", + "superagent": "^3.8.3", + "tcomb-validation": "^3.4.1", + "tslib": "^1.9.3" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -7355,6 +7425,11 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==" + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -8138,6 +8213,11 @@ } } }, + "html-comment-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", + "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==" + }, "html-encoding-sniffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", @@ -8997,6 +9077,14 @@ "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", "dev": true }, + "is-svg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz", + "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==", + "requires": { + "html-comment-regex": "^1.1.0" + } + }, "is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -9051,8 +9139,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -9179,6 +9266,11 @@ "istanbul-lib-report": "^3.0.0" } }, + "isutf8": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isutf8/-/isutf8-2.1.0.tgz", + "integrity": "sha512-rEMU6f82evtJNtYMrtVODUbf+C654mos4l+9noOueesUMipSWK6x3tpt8DiXhcZh/ZOBWYzJ9h9cNAlcQQnMiQ==" + }, "jest": { "version": "25.5.4", "resolved": "https://registry.npmjs.org/jest/-/jest-25.5.4.tgz", @@ -12290,6 +12382,11 @@ "minimist": "^1.2.5" } }, + "jsonschema": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.0.tgz", + "integrity": "sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==" + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -14547,8 +14644,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "progress": { "version": "2.0.3", @@ -15134,7 +15230,6 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -16512,6 +16607,11 @@ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", "dev": true }, + "spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + }, "spawn-command": { "version": "0.0.2-1", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", @@ -16853,7 +16953,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -16959,6 +17058,38 @@ } } }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -17115,6 +17246,26 @@ } } }, + "tc-auth-lib": { + "version": "github:topcoder-platform/tc-auth-lib#ae8be611e6f616df73097b90e403ff783bed9ea9", + "from": "github:topcoder-platform/tc-auth-lib#1.0.3", + "requires": { + "lodash": "^4.17.19" + } + }, + "tcomb": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/tcomb/-/tcomb-3.2.29.tgz", + "integrity": "sha512-di2Hd1DB2Zfw6StGv861JoAF5h/uQVu/QJp2g8KVbtfKnoHdBQl5M32YWq6mnSYBQ1vFFrns5B1haWJL7rKaOQ==" + }, + "tcomb-validation": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tcomb-validation/-/tcomb-validation-3.4.1.tgz", + "integrity": "sha512-urVVMQOma4RXwiVCa2nM2eqrAomHROHvWPuj6UkDGz/eb5kcy0x6P0dVt6kzpUZtYMNoAqJLWmz1BPtxrtjtrA==", + "requires": { + "tcomb": "^3.0.0" + } + }, "terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -18027,8 +18178,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.1.1", diff --git a/package.json b/package.json index dd4ec70..58c092c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,8 @@ "express": "^4.17.1", "joi": "^17.4.0", "lodash": "^4.17.21", + "filestack-js": "^1.7.7", + "filestack-react": "^2.0.0", "moment": "^2.29.1", "moment-timezone": "^0.5.33", "moment-duration-format": "^2.3.2", @@ -80,6 +82,7 @@ "rc-tooltip": "^4.2.3", "react": "^16.12.0", "react-date-range": "^1.1.3", + "prop-types": "^15.7.2", "react-dom": "^16.12.0", "react-redux": "^7.2.3", "react-select": "^1.3.0", @@ -94,9 +97,11 @@ "draft-js-export-html": "^1.2.0", "turndown": "^4.0.2", "react-markdown": "^4.3.1", + "react-css-super-themr": "^2.2.0", "react-stickynode": "^1.4.1", "markdown-it": "^8.4.1", "focus-trap-react": "^6.0.0", - "redux-promise-middleware": "^6.1.2" + "redux-promise-middleware": "^6.1.2", + "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.3" } } diff --git a/src/App.jsx b/src/App.jsx index b6291e9..adbafb7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import { useLocation, Router, Redirect } from "@reach/router"; import { usePreviousLocation } from "./utils/hooks"; import ChallengeList from "./routers/challenge-list"; import ChallengeDetail from "./routers/challenge-detail"; +import Submission from "./routers/submissions"; import "./styles/main.scss"; const App = () => { @@ -25,6 +26,7 @@ const App = () => { + ); diff --git a/src/actions/auth.js b/src/actions/auth.js index fa1d4f7..4f19902 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -7,6 +7,7 @@ import { createActions } from "redux-actions"; import { decodeToken } from "../utils/token"; import { getApiV3, getApiV5 } from "../services/challenge-api"; import { setErrorIcon, ERROR_ICON_TYPES } from "../utils/errors"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; /** * Helper method that checks for HTTP error response v5 and throws Error in this case. @@ -76,10 +77,17 @@ function setTcTokenV3(tokenV3) { return tokenV3; } +async function setAuthDone() { + const { tokenV3 } = await getAuthUserTokens(); + const user = tokenV3 ? decodeToken(tokenV3) : null; + return user; +} + export default createActions({ AUTH: { LOAD_PROFILE: loadProfileDone, SET_TC_TOKEN_V2: setTcTokenV2, SET_TC_TOKEN_V3: setTcTokenV3, + SET_AUTH_DONE: setAuthDone, }, }); diff --git a/src/actions/challenge.js b/src/actions/challenge.js index 19dbaf1..5256ad9 100644 --- a/src/actions/challenge.js +++ b/src/actions/challenge.js @@ -10,6 +10,7 @@ import { createActions } from "redux-actions"; import { decodeToken } from "../utils/token"; import { getService as getChallengesService } from "../services/challenges"; import { getService as getSubmissionService } from "../services/submissions"; +import challengeService from "../services/challenge"; import { getApi } from "../services/challenge-api"; import * as submissionUtil from "../utils/submission"; @@ -449,6 +450,10 @@ function getSubmissionInformationDone(challengeId, submissionId, tokenV3) { }); } +function getChallengeDone(challengeId) { + return challengeService.getChallenge(challengeId); +} + export default createActions({ CHALLENGE: { DROP_CHECKPOINTS: dropCheckpoints, @@ -476,5 +481,7 @@ export default createActions({ GET_MM_SUBMISSIONS_DONE: getMMSubmissionsDone, GET_SUBMISSION_INFORMATION_INIT: getSubmissionInformationInit, GET_SUBMISSION_INFORMATION_DONE: getSubmissionInformationDone, + GET_CHALLENGE_INIT: _.noop, + GET_CHALLENGE_DONE: getChallengeDone, }, }); diff --git a/src/actions/index.js b/src/actions/index.js index dd71fdc..d1ee873 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -7,12 +7,16 @@ import challengeListing from "./challenge-listing"; import auth from "./auth"; import page from "./page/challenge-details"; import errors from "./errors"; +import submission from "./submission"; +import submissionManagement from "./submissionManagement"; export default { challenges, filter, lookup, init, + submission, + submissionManagement, ...challenge, ...challengeListing, ...auth, diff --git a/src/actions/submission.js b/src/actions/submission.js new file mode 100644 index 0000000..9a67f87 --- /dev/null +++ b/src/actions/submission.js @@ -0,0 +1,36 @@ +/** + * actions.page.challenge-details.submission + * + * Description: + * Contains the Redux Actions for updating the Submission page UI + * and for for uploading submissions to back end + */ +import _ from "lodash"; +import { createActions } from "redux-actions"; +import service from "../services/submission"; + +/** + * Payload creator for the action that actually performs submission operation. + * @param {Object} data Data to submit. + * @param {Function} onProgress The callback to trigger with updates on the + * submission progress. + * @return Promise + */ +function submitDone(data, onProgress) { + return service.submit(data, onProgress); +} + +export default createActions({ + SUBMIT: { + SUBMIT_INIT: _.noop, + SUBMIT_DONE: submitDone, + SUBMIT_RESET: _.noop, + UPLOAD_PROGRESS: (percent) => percent, + SET_AGREED: (agreed) => agreed, + SET_FILE_PICKER_ERROR: (id, error) => ({ id, error }), + SET_FILE_PICKER_FILE_NAME: (id, fileName) => ({ id, fileName }), + SET_FILE_PICKER_UPLOAD_PROGRESS: (id, progress) => ({ id, progress }), + SET_FILE_PICKER_DRAGGED: (id, dragged) => ({ id, dragged }), + SET_SUBMISSION_FILESTACK_DATA: (data) => data, + }, +}); diff --git a/src/actions/submissionManagement.js b/src/actions/submissionManagement.js new file mode 100644 index 0000000..a3116de --- /dev/null +++ b/src/actions/submissionManagement.js @@ -0,0 +1,42 @@ +import _ from "lodash"; +import { createActions } from "redux-actions"; +import service from "../services/submission"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; +import { decodeToken } from "tc-auth-lib"; +import { triggerDownload } from "../utils"; + +function deleteSubmissionDone(submissionId) { + return service.deleteSubmission(submissionId); +} + +async function downloadSubmissionDone(track, submissionId) { + const blob = await service.downloadSubmission(track, submissionId); + triggerDownload( + `submission-${track.toLowerCase()}-${submissionId}.zip`, + blob + ); +} + +async function getMySubmissionsDone(challengeId) { + const { tokenV3 } = await getAuthUserTokens(); + const user = decodeToken(tokenV3); + const filters = { + challengeId, + memberId: user.userId, + }; + + return service.getSubmissions(filters); +} + +export default createActions({ + MY_SUBMISSIONS: { + SHOW_DETAILS: _.identity, + CANCEL_DELETE: _.noop, + CONFIRM_DELETE: _.identity, + DELETE_SUBMISSION_INIT: _.noop, + DELETE_SUBMISSION_DONE: deleteSubmissionDone, + DOWNLOAD_SUBMISSION_DONE: downloadSubmissionDone, + GET_MY_SUBMISSIONS_INIT: _.noop, + GET_MY_SUBMISSIONS_DONE: getMySubmissionsDone, + }, +}); diff --git a/src/assets/icons/IconCloudDownload.svg b/src/assets/icons/IconCloudDownload.svg new file mode 100644 index 0000000..b17e3bc --- /dev/null +++ b/src/assets/icons/IconCloudDownload.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/IconMinimalDown.svg b/src/assets/icons/IconMinimalDown.svg new file mode 100644 index 0000000..919a0ae --- /dev/null +++ b/src/assets/icons/IconMinimalDown.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/assets/icons/IconMinimalLeft.svg b/src/assets/icons/IconMinimalLeft.svg new file mode 100644 index 0000000..287bf69 --- /dev/null +++ b/src/assets/icons/IconMinimalLeft.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/assets/icons/IconMinimalRight.svg b/src/assets/icons/IconMinimalRight.svg new file mode 100644 index 0000000..d2905c9 --- /dev/null +++ b/src/assets/icons/IconMinimalRight.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/assets/icons/IconMinimalUp.svg b/src/assets/icons/IconMinimalUp.svg new file mode 100644 index 0000000..9bf3e2d --- /dev/null +++ b/src/assets/icons/IconMinimalUp.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/assets/icons/IconShare.svg b/src/assets/icons/IconShare.svg new file mode 100644 index 0000000..2c731ab --- /dev/null +++ b/src/assets/icons/IconShare.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/assets/icons/IconSquareDownload.svg b/src/assets/icons/IconSquareDownload.svg new file mode 100644 index 0000000..9ef5cee --- /dev/null +++ b/src/assets/icons/IconSquareDownload.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/icons/IconTrashSimple.svg b/src/assets/icons/IconTrashSimple.svg new file mode 100644 index 0000000..2b1e18c --- /dev/null +++ b/src/assets/icons/IconTrashSimple.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/assets/icons/logo_topcoder.svg b/src/assets/icons/logo_topcoder.svg new file mode 100644 index 0000000..3fa9d3a --- /dev/null +++ b/src/assets/icons/logo_topcoder.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/robot-embarassed.svg b/src/assets/icons/robot-embarassed.svg new file mode 100644 index 0000000..0d97436 --- /dev/null +++ b/src/assets/icons/robot-embarassed.svg @@ -0,0 +1,20 @@ + + + robot-embarresed + Created with Sketch. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/robot-happy.svg b/src/assets/icons/robot-happy.svg new file mode 100644 index 0000000..0fe2251 --- /dev/null +++ b/src/assets/icons/robot-happy.svg @@ -0,0 +1,27 @@ + + + + + robot + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/AccessDenied/index.jsx b/src/components/AccessDenied/index.jsx new file mode 100644 index 0000000..48d71e2 --- /dev/null +++ b/src/components/AccessDenied/index.jsx @@ -0,0 +1,70 @@ +/* global window */ + +import React from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import { PrimaryButton } from "../Buttons"; +import TopcoderLogo from "assets/icons/logo_topcoder.svg"; +import { ACCESS_DENIED_REASON } from "../../constants"; + +import "./style.scss"; + +const AccessDenied = ({ cause, redirectLink, children }) => { + switch (cause) { + case ACCESS_DENIED_REASON.NOT_AUTHENTICATED: { + return ( +
+ +
+ You must be authenticated to access this page. +
+
+ { + const retUrl = encodeURIComponent(window.location.href); + window.location = `${process.env.URL.AUTH}/member?retUrl=${retUrl}`; + event.preventDefault(); + }} + > + Log In Here + +
+
+ ); + } + case ACCESS_DENIED_REASON.NOT_AUTHORIZED: + return ( +
+ +
You are not authorized to access this page.
+ {children} +
+ ); + case ACCESS_DENIED_REASON.HAVE_NOT_SUBMITTED_TO_THE_CHALLENGE: + return ( +
+ +
You have not submitted to this challenge
+ Back to the challenge +
+ ); + default: + return
; + } +}; + +AccessDenied.defaultProps = { + cause: ACCESS_DENIED_REASON.NOT_AUTHENTICATED, + redirectLink: "", + children: null, +}; + +AccessDenied.propTypes = { + cause: PT.oneOf(_.toArray(ACCESS_DENIED_REASON)), + redirectLink: PT.string, + children: PT.node, +}; + +export default AccessDenied; diff --git a/src/components/AccessDenied/style.scss b/src/components/AccessDenied/style.scss new file mode 100644 index 0000000..3921a58 --- /dev/null +++ b/src/components/AccessDenied/style.scss @@ -0,0 +1,48 @@ +@import 'styles/variables'; +@import 'styles/mixins'; + +.access-denied { + @include tc-body-lg; + + padding-top: 100px; + text-align: center; + width: 100%; +} + +.msg { + padding-top: 48px; + + a { + cursor: pointer; + } +} + +.joinNow { + &, + &:hover, + &:visited { + color: $tc-light-blue; + text-decoration: underline; + } +} + +.policy { + font-weight: bold; + margin-right: 24px; + + &, + &:hover, + &:visited { + color: $tc-light-blue; + text-decoration: underline; + } +} + +.copyright { + @include roboto-regular; + + color: $tc-gray-60; + font-size: 12pt; + margin-top: 128px; + text-transform: uppercase; +} diff --git a/src/components/Buttons/index.jsx b/src/components/Buttons/index.jsx new file mode 100644 index 0000000..b4886a6 --- /dev/null +++ b/src/components/Buttons/index.jsx @@ -0,0 +1,214 @@ +import React from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import { themr } from "react-css-super-themr"; +import { Link as RouterLink } from "@reach/router"; +import dangerTheme from "./themes/danger.scss"; +import defaultTheme from "./themes/default.scss"; +import ghostTheme from "./themes/ghost.scss"; +import primaryTheme from "./themes/primary.scss"; +import secondaryTheme from "./themes/secondary.scss"; + +import "./styles.scss"; + +const Link = (props) => { + const { + children, + className, + enforceA, + onClick, + onMouseDown, + openNewTab, + replace, + to, + } = props; + /* Renders Link as element if: + * - It is opted explicitely by `enforceA` prop; + * - It should be opened in a new tab; + * - It is an absolte URL (starts with http:// or https://); + * - It is anchor link (starts with #). */ + if (enforceA || openNewTab || to.match(/^(#|https?:\/\/)/)) { + return ( + + {children} + + ); + } + + const linkProps = _.omit(props, ["children", "enforceA", "openNewTab"]); + + /* Otherwise we render the link as React Router's Link or NavLink element. */ + return React.createElement( + RouterLink, + { + ...linkProps, + replace, + onClick: (e) => { + /* If a custom onClick(..) handler was provided we execute it. */ + if (onClick) onClick(e); + + /* The link to the current page will scroll to the top of the page. */ + window.scroll(0, 0); + }, + }, + children + ); +}; + +Link.defaultProps = { + children: null, + className: null, + enforceA: false, + onClick: null, + onMouseDown: null, + openNewTab: false, + replace: false, + to: "", +}; + +Link.propTypes = { + children: PT.node, + className: PT.string, + enforceA: PT.bool, + onClick: PT.func, + onMouseDown: PT.func, + openNewTab: PT.bool, + replace: PT.bool, + to: PT.oneOfType([PT.object, PT.string]), +}; + +const ButtonComponent = ({ + active, + children, + disabled, + enforceA, + onClick, + onMouseDown, + openNewTab, + replace, + size, + theme, + to, + type, +}) => { + let className = theme.button; + if (theme[size]) className += ` ${theme[size]}`; + if (active && theme.active) className += ` ${theme.active}`; + if (disabled) { + if (theme.disabled) className += ` ${theme.disabled}`; + return
{children}
; + } + if (to) { + if (theme.link) className += ` ${theme.link}`; + return ( + + {children} + + ); + } + + if (theme.regular) { + className += ` ${theme.regular}`; + } + + return ( + + ); +}; + +ButtonComponent.defaultProps = { + active: false, + children: null, + disabled: false, + enforceA: false, + onClick: null, + onMouseDown: null, + openNewTab: false, + replace: false, + size: null, + to: null, + type: "button", +}; + +ButtonComponent.propTypes = { + active: PT.bool, + children: PT.node, + disabled: PT.bool, + enforceA: PT.bool, + onClick: PT.func, + onMouseDown: PT.func, + openNewTab: PT.bool, + replace: PT.bool, + size: PT.string, + theme: PT.shape({ + button: PT.string.isRequired, + disabled: PT.string, + link: PT.string, + regular: PT.string, + }).isRequired, + to: PT.oneOfType([PT.object, PT.string]), + type: PT.oneOf(["button", "reset", "submit"]), +}; + +export const DefaultButton = themr( + "DefaultButton", + defaultTheme +)(ButtonComponent); +export const DangerButton = themr("DangerButton", dangerTheme)(ButtonComponent); +export const GhostButton = themr("GhostButton", ghostTheme)(ButtonComponent); +export const PrimaryButton = themr( + "PrimaryButton", + primaryTheme +)(ButtonComponent); +export const SecondaryButton = themr( + "SecondaryButton", + secondaryTheme +)(ButtonComponent); + +const Button = ({ children, className, onClick }) => { + return ( + + ); +}; + +Button.defaultProps = { + className: "tc-blue-btn", + onClick: _.noop, + children: null, +}; + +Button.propTypes = { + className: PT.string, + onClick: PT.func, + children: PT.node, +}; + +export default Button; diff --git a/src/components/Buttons/styles.scss b/src/components/Buttons/styles.scss new file mode 100644 index 0000000..1ea1a73 --- /dev/null +++ b/src/components/Buttons/styles.scss @@ -0,0 +1,24 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +/* Button style */ +$button-space-32: $base-unit * 6 + 2; +$button-space-15: $base-unit * 3; +$btn-red-color: #f63c32; + +.buttons-default { + &.tc-bg-btn { + line-height: $button-space-32; + } + + &.tc-red-btn, + &.tc-blue-btn, + &.tc-outline-btn { + font-size: 13px; + padding: $base-unit - 1 $button-space-15; + } + + &.red { + background: $btn-red-color; + } +} diff --git a/src/components/Buttons/themes/danger.scss b/src/components/Buttons/themes/danger.scss new file mode 100644 index 0000000..ed09924 --- /dev/null +++ b/src/components/Buttons/themes/danger.scss @@ -0,0 +1,46 @@ +/** + * Danger button (red background, white text). + */ +@import "styles/variables"; +@import "default"; + +@mixin button { + background: $tc-red-110; + border-color: transparent; + color: $tc-white; +} + +.button.disabled { + @include button; + + background-color: $tc-gray-20; + opacity: 1; +} + +.button.link, +.button.regular { + @include button; + + &:visited { + color: $tc-white; + } + + &:focus { + box-shadow: 0 0 2px 1px #ffd4d1; + border-color: $tc-red; + outline: none; + } + + &:hover { + background-image: linear-gradient(to bottom, #ff5b52, #f22f24); + border-color: transparent; + color: $tc-white; + } + + &:active { + background-color: $tc-red-110; + background-image: none; + box-shadow: inset 0 1px 3px 0 rgba(71, 71, 79, 0.38); + border-color: transparent; + } +} diff --git a/src/components/Buttons/themes/default.scss b/src/components/Buttons/themes/default.scss new file mode 100644 index 0000000..54c69d2 --- /dev/null +++ b/src/components/Buttons/themes/default.scss @@ -0,0 +1,98 @@ +/** + * The default button theme: white button with dark gray label, gray border, and + * some extra effects in different state. + * + * The comments in this file explain how to write a custom button theme. + */ + @import "styles/variables"; +@import "styles/mixins"; + +/* Generic button of medium size. */ +@mixin button { + @include tc-label-lg; + + align-items: center; + background: $tc-white; + border: solid 1px $tc-gray-30; + border-radius: 4px; + box-sizing: border-box; + color: $tc-gray-80; + display: inline-flex; + justify-content: center; + min-height: 40px; + margin: 5px; + padding: 5px 23px; + text-align: center; + vertical-align: middle; +} + +/* NOTE: All CSS rules below use two classes, to have higher specifity, thus + * avoiding overriding of these rules by parent rules in some scenarious. */ + +/* Additional styling for a disabled button. */ +.button.disabled { + @include button; + + cursor: not-allowed; + opacity: 0.3; +} + +/* .link and .regular classes are applied only to active button-like links, +* and real buttons (rendered as + retry()}>Try Again +
+ )} + {submitDone && !error && ( +

+ Thanks for participating! We’ve received your submission and will + send you an email shortly to confirm and explain what happens next. +

+ )} + {submitDone && !error && ( +
+ {track === COMPETITION_TRACKS.DES ? ( + + + back()} + > + View My Submissions + + + ) : ( + + + back()} + > + Back to Challenge + + + )} +
+ )} + + + ); +}; + +Uploading.defaultProps = {}; + +Uploading.propTypes = { + challengeId: PT.string, + challengeName: PT.string, + error: PT.string, + isSubmitting: PT.bool, + submitDone: PT.string, + reset: PT.func, + retry: PT.func, + track: PT.string, + uploadProgress: PT.number, + back: PT.func, +}; + +export default Uploading; diff --git a/src/containers/Submission/Submit/Uploading/styles.scss b/src/containers/Submission/Submit/Uploading/styles.scss new file mode 100644 index 0000000..54bfb41 --- /dev/null +++ b/src/containers/Submission/Submit/Uploading/styles.scss @@ -0,0 +1,90 @@ +@import "~styles/variables"; +@import "~styles/mixins"; + +.container { + @include roboto-regular; + + align-items: center; + width: 100%; + background-color: white; + left: 0; + z-index: 100000; + display: flex; + justify-content: center; + position: relative; + padding: 30px; +} + +.link, +.link:visited { + font-size: 20px; + line-height: 30px; + color: #1a85ff; +} + +.uploading { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + .error-msg { + width: 500px; + margin-bottom: 45px; + padding: 5px 13px; + background: #fff4f4; + border: 1px solid #ffd4d1; + color: #f22f24; + font-size: 13px; + border-radius: 2px; + font-style: italic; + text-align: center; + } + + .progress-container { + width: 650px; + height: 10px; + background-color: $tc-gray-10; + border-radius: 12px; + + .progress-bar { + width: 0%; + height: 10px; + background-color: $tc-dark-blue; + border-radius: 12px; + } + } + + h3 { + font-size: 20px; + line-height: 30px; + color: #3d3d3d; + } + + svg { + margin-top: 50px; + } + + p { + font-size: 15px; + color: #a3a3ad; + width: 80%; + text-align: center; + line-height: 18px; + margin-bottom: 30px; + margin-top: 30px; + } + + .submitting { + margin-top: 20px; + font-size: 13px; + color: $tc-black; + } + + .button-container { + align-items: center; + flex-wrap: wrap; + display: flex; + justify-content: center; + } +} diff --git a/src/containers/Submission/Submit/index.jsx b/src/containers/Submission/Submit/index.jsx new file mode 100644 index 0000000..9f9e2a0 --- /dev/null +++ b/src/containers/Submission/Submit/index.jsx @@ -0,0 +1,122 @@ +import React from "react"; +import PT from "prop-types"; +import * as util from "../../../utils/submission"; +import Header from "./Header"; +import SubmitForm from "./SubmitForm"; + +import "./styles.scss"; + +const Submit = ({ + challengeId, + challengeName, + phases, + status, + winners, + groups, + handle, + communityList, + isCommunityListLoaded, + + track, + agreed, + filePickers, + submissionFilestackData, + userId, + + errorMsg, + isSubmitting, + submitDone, + uploadProgress, + + resetForm, + setAgreed, + setFilePickerError, + setFilePickerFileName, + setFilePickerUploadProgress, + setFilePickerDragged, + setSubmissionFilestackData, + submit, +}) => { + const submissionEnded = util.isSubmissionEnded({ status, phases }); + const canSubmitFinalFixes = util.canSubmitFinalFixes( + { winners, phases }, + handle + ); + + const submissionPermitted = !submissionEnded || canSubmitFinalFixes; + + return ( +
+
+
+ {submissionPermitted ? ( + + ) : ( +
+

Submissions are not permitted at this time.

+
+ )} +
+
+ ); +}; + +Submit.defaultProps = {}; + +Submit.propTypes = { + challengeId: PT.string, + challengeName: PT.string, + phases: PT.arrayOf(PT.shape()), + status: PT.string, + winners: PT.arrayOf(PT.shape()), + groups: PT.arrayOf(PT.shape()), + handle: PT.string, + communityList: PT.arrayOf(PT.shape()), + isCommunityListLoaded: PT.bool, + + track: PT.string, + agreed: PT.bool, + filePickers: PT.arrayOf(PT.shape()), + submissionFilestackData: PT.shape(), + userId: PT.number, + + errorMsg: PT.string, + isSubmitting: PT.bool, + submitDone: PT.bool, + uploadProgress: PT.number, + + resetForm: PT.func, + setAgreed: PT.func, + setFilePickerError: PT.func, + setFilePickerFileName: PT.func, + setFilePickerUploadProgress: PT.func, + setFilePickerDragged: PT.func, + setSubmissionFilestackData: PT.func, + submit: PT.func, +}; + +export default Submit; diff --git a/src/containers/Submission/Submit/styles.scss b/src/containers/Submission/Submit/styles.scss new file mode 100644 index 0000000..697bbba --- /dev/null +++ b/src/containers/Submission/Submit/styles.scss @@ -0,0 +1,37 @@ +@import "~styles/mixins"; + +.container { + display: flex; + flex: 1; + justify-content: center; + padding: 80px 0 40px; + background: #fff; + min-height: calc(100vh - var(--navbarHeight, 60px)); + + @include md-to-lg { + padding-bottom: 0; + } + + @include xs-to-lg { + padding: 10px; + } + + .content { + display: flex; + flex-direction: column; + + @include md-to-lg { + width: 100%; + } + + @include xl { + width: 1242px; + } + + .not-permitted { + background-color: white; + text-align: center; + padding: 40px; + } + } +} diff --git a/src/containers/Submission/index.jsx b/src/containers/Submission/index.jsx new file mode 100644 index 0000000..9c1e1ea --- /dev/null +++ b/src/containers/Submission/index.jsx @@ -0,0 +1,244 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import PT from "prop-types"; +import { connect } from "react-redux"; +import { navigate } from "@reach/router"; +import { PrimaryButton } from "components/Buttons"; +import AccessDenied from "components/AccessDenied"; +import LoadingIndicator from "components/LoadingIndicator"; +import { ACCESS_DENIED_REASON, CHALLENGES_URL } from "../../constants"; +import Submit from "./Submit"; +import actions from "../../actions"; +import { isLegacyId, isUuid } from "../../utils/challenge"; + +const Submission = ({ + id, + challengeId, + challengeName, + isRegistered, + phases, + status, + winners, + groups, + isAuthInitialized, + communityList, + isCommunityListLoaded, + getCommunityList, + isLoadingChallenge, + isChallengeLoaded, + + track, + agreed, + filePickers, + submissionFilestackData, + userId, + handle, + + errorMsg, + isSubmitting, + submitDone, + uploadProgress, + + getChallenge, + submit, + resetForm, + setAgreed, + setFilePickerError, + setFilePickerFileName, + setFilePickerUploadProgress, + setFilePickerDragged, + setSubmissionFilestackData, + setAuth, +}) => { + const propsRef = useRef(); + propsRef.current = { + id, + challengeId, + getCommunityList, + setAuth, + getChallenge, + }; + + useLayoutEffect(() => { + if (isLegacyId(propsRef.current.id) || isUuid(propsRef.current.id)) { + propsRef.current.getCommunityList(); + propsRef.current.setAuth(); + propsRef.current.getChallenge(propsRef.current.id); + } else { + navigate(CHALLENGES_URL); + } + }, []); + + useEffect(() => { + if (isChallengeLoaded && isLegacyId(propsRef.current.id)) { + navigate(`${CHALLENGES_URL}/${propsRef.current.challengeId}/submit`); + } + }, [isChallengeLoaded]); + + if (isLoadingChallenge || !isAuthInitialized) { + return ; + } + + if (!isChallengeLoaded) { + return null; + } + + if (!isRegistered) { + return ( + + + Go to Challenge Details + + + ); + } + + return ( + + ); +}; + +Submission.defaultProps = {}; + +Submission.propTypes = { + id: PT.string, + challengeId: PT.string, + challengeName: PT.string, + isRegistered: PT.bool, + phases: PT.arrayOf(PT.shape()), + status: PT.string, + winners: PT.arrayOf(PT.shape()), + groups: PT.arrayOf(PT.shape()), + isAuthInitialized: PT.bool, + communityList: PT.arrayOf(PT.shape()), + isCommunityListLoaded: PT.bool, + getCommunityList: PT.func, + isLoadingChallenge: PT.bool, + isChallengeLoaded: PT.bool, + + track: PT.string, + agreed: PT.bool, + filePickers: PT.arrayOf(PT.shape()), + submissionFilestackData: PT.shape(), + userId: PT.number, + handle: PT.string, + + errorMsg: PT.string, + isSubmitting: PT.bool, + submitDone: PT.bool, + uploadProgress: PT.number, + + getChallenge: PT.func, + submit: PT.func, + resetForm: PT.func, + setAgreed: PT.func, + setFilePickerError: PT.func, + setFilePickerFileName: PT.func, + setFilePickerUploadProgress: PT.func, + setFilePickerDragged: PT.func, + setSubmissionFilestackData: PT.func, + setAuth: PT.func, +}; + +const mapStateToProps = (state, ownProps) => { + const challenge = state?.challenge?.challenge; + + return { + id: ownProps.challengeId, + challengeId: challenge?.id, + challengeName: challenge?.name, + isRegistered: challenge?.isRegistered, + phases: challenge?.phases, + status: challenge?.status, + winners: challenge?.winners, + groups: challenge?.groups, + handle: state.auth.user ? state.auth.user.handle : "", + isAuthInitialized: state.auth.isAuthInitialized, + communityList: state.lookup.subCommunities, + isCommunityListLoaded: state.lookup.isSubCommunitiesLoaded, + isLoadingChallenge: state.challenge?.isLoadingChallenge, + isChallengeLoaded: state.challenge?.isChallengeLoaded, + + track: challenge?.track, + agreed: state.submission.agreed, + filePickers: state.submission.filePickers, + submissionFilestackData: state.submission.submissionFilestackData, + userId: state.auth.user ? state.auth.user.userId : null, + + errorMsg: state.submission.submitErrorMsg, + isSubmitting: state.submission.isSubmitting, + submitDone: state.submission.submitDone, + uploadProgress: state.submission.uploadProgress, + }; +}; + +const mapDispatchToProps = (dispatch) => { + const onProgress = (event) => + dispatch(actions.submission.submit.uploadProgress(event)); + + return { + getCommunityList: () => { + dispatch(actions.lookup.getCommunityListDone()); + }, + setAuth: () => { + dispatch(actions.auth.setAuthDone()); + }, + getChallenge: (challengeId) => { + dispatch(actions.challenge.getChallengeInit(challengeId)); + dispatch(actions.challenge.getChallengeDone(challengeId)); + }, + submit: (data) => { + dispatch(actions.submission.submit.submitInit()); + dispatch(actions.submission.submit.submitDone(data, onProgress)); + }, + setAgreed: (agreed) => { + dispatch(actions.submission.submit.setAgreed(agreed)); + }, + setFilePickerError: (id, error) => { + dispatch(actions.submission.submit.setFilePickerError(id, error)); + }, + setFilePickerFileName: (id, fileName) => { + dispatch(actions.submission.submit.setFilePickerFileName(id, fileName)); + }, + setFilePickerDragged: (id, dragged) => { + dispatch(actions.submission.submit.setFilePickerDragged(id, dragged)); + }, + setFilePickerUploadProgress: (id, p) => { + dispatch(actions.submission.submit.setFilePickerUploadProgress(id, p)); + }, + setSubmissionFilestackData: (id, data) => { + dispatch(actions.submission.submit.setSubmissionFilestackData(id, data)); + }, + resetForm: () => { + dispatch(actions.submission.submit.submitReset()); + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Submission); diff --git a/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/index.jsx b/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/index.jsx new file mode 100644 index 0000000..bbe700c --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/index.jsx @@ -0,0 +1,104 @@ +import React from "react"; +import PT from "prop-types"; + +import "./styles.scss"; + +const ScreeningDetails = ({ screening, helpPageUrl }) => { + const hasWarnings = screening.warnings; + const hasStatus = screening.status; + const hasStatusPassed = hasStatus === "passed"; + const hasStatusFailed = hasStatus === "failed"; + const hasPending = screening.status === "pending"; + const warnLength = screening.warnings && hasWarnings.length; + + let statusInfo; + if (hasPending) { + statusInfo = { + title: "Pending", + classname: "pending", + message: + "Your submission has been received, and will be screened after the end of the phase", + }; + } else if (hasStatusPassed && !hasWarnings) { + statusInfo = { + title: "Passed Screening", + classname: "passed", + message: "You have passed screening.", + }; + } else if (hasStatusFailed && !hasWarnings) { + statusInfo = { + title: "Failed Screening", + classname: "failed", + message: "You have failed screening", + }; + } else if (hasStatusPassed && hasWarnings) { + statusInfo = { + title: "Passed Screening with Warnings", + classname: "passed", + message: `You have passed screening, but the screener has given you ${warnLength} warnings that you must fix in round 2.`, + }; + } else if (hasStatusFailed && hasWarnings) { + statusInfo = { + title: "Failed Screening with Warnings", + classname: "failed", + message: + "You have failed screening and the screener has given you the following warning.", + }; + } else { + statusInfo = { + title: "", + classname: "", + message: + "Your submission has been received, and will be evaluated during Review phase.", + }; + } + + const warnings = (screening.warnings || []).map((warning, i) => ( +
+
+ Warning {`${1 + i} : ${warning.brief}`} +
+

{warning.details}

+
+ )); + + return ( +
+
+

+ {statusInfo.title} +

+
+

{statusInfo.message}

+
+ {warnings} + {(hasStatusFailed || (hasStatusPassed && hasWarnings)) && ( +

+ Need more info on how to pass screening? Go to help to read Rules & + Policies. +

+ )} +
+ + Help + +
+
+
+ ); +}; + +ScreeningDetails.defaultProps = { + screening: {}, +}; + +ScreeningDetails.propTypes = { + screening: PT.shape({}), + helpPageUrl: PT.string, +}; + +export default ScreeningDetails; diff --git a/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/styles.scss b/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/styles.scss new file mode 100644 index 0000000..8f0b815 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/ScreeningDetails/styles.scss @@ -0,0 +1,90 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$status-space-10: $base-unit * 2; +$status-space-15: $base-unit * 3; +$status-space-20: $base-unit * 4; +$status-space-25: $base-unit * 5; +$gray-color: $tc-gray-80; +$green-color: $tc-green; +$red-color: $tc-red; + +.online-review-link { + background: none; +} + +.screening-details { + background: $tc-gray-neutral-light; + font-weight: 400; + font-size: 15px; + color: $gray-color; + letter-spacing: 0; + line-height: $status-space-25; + + .screening-details-head { + display: flex; + margin-bottom: $base-unit; + + .status-title { + font-weight: 700; + color: $tc-orange; + letter-spacing: 0; + line-height: $status-space-20; + + .passed { + color: $green-color; + } + + &.failed { + color: $red-color; + } + + &.pending { + color: $gray-color; + } + } + + .online-review-link { + display: block; + margin-left: auto; + line-height: $status-space-20; + font-weight: 400; + font-size: 13px; + color: $tc-dark-blue-110; + text-decoration: underline; + padding: 0; + border: none; + text-transform: capitalize; + + &:hover { + opacity: 0.7; + } + + &:focus { + outline: none; + } + } + } + + .screening-warning { + margin-top: $status-space-15; + } + + .more-info { + margin-top: $status-space-15; + margin-bottom: $base-unit; + } + + .warning-bold { + font-weight: 700; + line-height: $status-space-20; + } + + .help-btn { + text-align: right; + } + + .help-link { + padding: 1px 6px; + } +} diff --git a/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/index.jsx b/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/index.jsx new file mode 100644 index 0000000..04deef1 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/index.jsx @@ -0,0 +1,66 @@ +import React from "react"; +import PT from "prop-types"; + +import "./styles.scss"; + +const ScreeningStatus = ({ screening, onShowDetails, submissionId }) => { + const hasWarnings = screening.warnings; + const hasStatus = screening.status; + const hasStatusPassed = hasStatus === "passed"; + const hasStatusFailed = hasStatus === "failed"; + const hasPending = screening.status === "pending"; + const warnLength = screening.warnings && hasWarnings.length; + + let className; + if (hasPending) { + className = "pending"; + } else if (hasStatusPassed && !hasWarnings) { + className = "pass-with-no-warn"; + } else if (hasStatusFailed && !hasWarnings) { + className = "fail-with-no-warn"; + } else { + className = "has-warn"; + } + + let statusClassName; + if (hasStatusPassed && hasWarnings) { + statusClassName = "passed"; + } else if (hasStatusFailed && hasWarnings) { + statusClassName = "failed"; + } else { + statusClassName = ""; + } + + return ( + + ); +}; + +ScreeningStatus.defaultProps = {}; + +ScreeningStatus.propTypes = { + screening: PT.shape(), + onShowDetails: PT.func, + submissionId: PT.string, +}; + +export default ScreeningStatus; diff --git a/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/styles.scss b/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/styles.scss new file mode 100644 index 0000000..86e5937 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/ScreeningStatus/styles.scss @@ -0,0 +1,70 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$status-space-10: $base-unit * 2; +$status-space-20: $base-unit * 4; +$status-space-40: $base-unit * 8; +$gray-color: #a3a3ad; +$green-color: #60c700; +$red-color: #f22f24; + +.status { + font-weight: 700; + display: inline-block; + padding: $base-unit $status-space-10; + border-radius: $status-space-40 0 0 $status-space-40; + text-transform: capitalize; + + &.passed { + background: $tc-orange; + } + + &.failed { + background: $red-color; + } +} + +.screening-status { + background: $tc-gray-50; + border-radius: $status-space-40; + font-weight: 400; + font-size: 13px; + line-height: $status-space-20; + color: $tc-white; + padding: $base-unit $status-space-10; + display: inline-block; + text-transform: initial; + cursor: pointer; + + &.has-warn { + padding: 0 $status-space-10 0 0; + } + + &.pass-with-no-warn { + background: $green-color; + + .status { + padding: 0 $base-unit; + } + } + + &.fail-with-no-warn { + background: $red-color; + + .status { + padding: 0 $base-unit; + } + } + + &.pending { + background: transparent; + font-size: 15px; + color: $gray-color; + font-style: italic; + } +} + +.warning { + padding-left: $status-space-10; + display: inline-block; +} diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/index.jsx b/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/index.jsx new file mode 100644 index 0000000..e37c43d --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/index.jsx @@ -0,0 +1,86 @@ +import React from "react"; +import PT from "prop-types"; +import moment from "moment"; +import ScreeningStatus from "../ScreeningStatus"; +import DeleteIcon from "assets/icons/IconTrashSimple.svg"; +import DownloadIcon from "assets/icons/IconSquareDownload.svg"; +import ExpandIcon from "assets/icons/IconMinimalDown.svg"; +import { COMPETITION_TRACKS, CHALLENGE_STATUS } from "../../../../constants"; + +import "./styles.scss"; + +const SubmissionRow = ({ + submission, + showScreeningDetails, + track, + onDownload, + onDelete, + onShowDetails, + status, + allowDelete, +}) => { + const formatDate = (date) => + moment(+new Date(date)).format("MMM DD, YYYY hh:mm A"); + + return ( + + + {submission.id} +
{submission.legacySubmissionId}
+ + {submission.type} + {formatDate(submission.created)} + {track === COMPETITION_TRACKS.DES && ( + + {submission.screening && ( + + )} + + )} + +
+ + {status !== CHALLENGE_STATUS.COMPLETED && + track !== COMPETITION_TRACKS.DES && ( + + )} + +
+ + + ); +}; + +SubmissionRow.defaultProps = {}; + +SubmissionRow.propTypes = { + submission: PT.shape({}), + showScreeningDetails: PT.bool, + track: PT.string, + onDownload: PT.func, + onDelete: PT.func, + onShowDetails: PT.func, + status: PT.string, + allowDelete: PT.bool, +}; + +export default SubmissionRow; diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/styles.scss b/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/styles.scss new file mode 100644 index 0000000..223b44d --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionRow/styles.scss @@ -0,0 +1,166 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$submission-space-10: $base-unit * 2; +$submission-space-20: $base-unit * 4; +$submission-space-25: $base-unit * 5; +$submission-space-50: $base-unit * 10; + +.submission-row { + width: 100%; + font-size: 15px; + color: $tc-black; + font-weight: 400; + + @include xs-to-sm { + display: block; + position: relative; + padding: 10px 0; + } + + td { + vertical-align: middle; + padding: $submission-space-20; + background: $tc-white; + border-top: 1px solid $tc-gray-10; + + @include xs-to-lg { + padding: $submission-space-10; + } + + @include xs-to-sm { + display: block; + border: none; + } + + &.no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + + &.dev-details { + padding-right: 60px; + } + } + + .preview-col { + @include xs-to-sm { + float: left; + } + + .design-img { + width: 90px; + height: 90px; + + @include xs-to-sm { + width: 80px; + height: 80px; + } + } + + .dev-img { + width: 40px; + height: 40px; + } + } + + .id-col { + font-weight: 700; + } + + .date-col { + color: $tc-gray-50; + font-weight: 400; + line-height: $submission-space-20; + + @include xs-to-sm { + padding: 0 10px; + } + } + + .legacy-id { + color: $tc-gray-50; + } + + .status-col { + text-align: center; + + button { + background: none; + border: none; + padding: 0; + + .pending { + text-transform: initial; + font-size: 15px; + color: $tc-gray-40; + line-height: $submission-space-20; + } + } + } + + .action-col { + text-align: center; + min-width: 120px; + + @include xs-to-sm { + position: absolute; + right: 0; + top: 10px; + padding: 10px 0; + min-width: 100px; + + svg { + width: 14px; + height: 14px; + } + } + + path { + fill: $tc-gray-80; + } + + .delete-icon { + margin: 0 0 0 24px; + + @include xs-to-sm { + margin-left: 15px; + } + } + + button { + background: none; + border: 0; + font-size: 0; + padding: 0; + line-height: 0; + display: inline-block; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + .expand-icon { + transition: all 0ms; + margin-left: 24px; + + @include xs-to-sm { + margin-left: 15px; + } + + &.expanded { + transform: rotate(180deg); + } + } + } + + .status-col button:focus { + outline: none; + } +} diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/index.jsx b/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/index.jsx new file mode 100644 index 0000000..bbd3d8c --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/index.jsx @@ -0,0 +1,34 @@ +import React from "react"; +import PT from "prop-types"; +import ScreeningDetails from "../ScreeningDetails"; + +import "./styles.scss"; + +const SubmissionRowExpanded = ({ + submission, + showScreeningDetails, + helpPageUrl, +}) => { + return ( + + {showScreeningDetails && ( + + + + )} + + ); +}; + +SubmissionRowExpanded.defaultProps = {}; + +SubmissionRowExpanded.propTypes = { + submission: PT.shape({}), + showScreeningDetails: PT.bool, + helpPageUrl: PT.string, +}; + +export default SubmissionRowExpanded; diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/styles.scss b/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/styles.scss new file mode 100644 index 0000000..e65de41 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionRowExpanded/styles.scss @@ -0,0 +1,27 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$submission-space-10: $base-unit * 2; +$submission-space-20: $base-unit * 4; +$submission-space-25: $base-unit * 5; +$submission-space-50: $base-unit * 10; + +.submission-row { + width: 100%; + font-size: 15px; + color: $tc-black; + font-weight: 400; + + td { + vertical-align: middle; + padding: $submission-space-20; + background: $tc-white; + border-top: 1px solid $tc-gray-10; + line-height: 12px; + + &.dev-details { + background: $tc-gray-neutral-light; + padding-right: 60px; + } + } +} diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/index.jsx b/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/index.jsx new file mode 100644 index 0000000..34c2dd8 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/index.jsx @@ -0,0 +1,94 @@ +import React from "react"; +import PT from "prop-types"; +import moment from "moment"; +import { COMPETITION_TRACKS } from "../../../../constants"; +import SubmissionRow from "../SubmissionRow"; +import SubmissionRowExpanded from "../SubmissionRowExpanded"; + +import "./styles.scss"; + +const SubmissionTable = ({ + submissions, + showDetails, + track, + onDelete, + helpPageUrl, + onDownload, + onShowDetails, + status, + submissionPhaseStartDate, +}) => { + const headerRow = ( + + ID + Type + Submission Date + {track === COMPETITION_TRACKS.DES && ( + Screening Status + )} + Actions + + ); + + const emptyRow = ( + + + You have no submission uploaded so far. + + + ); + + return ( +
+ + {headerRow} + + {!submissions || submissions.length === 0 + ? emptyRow + : submissions.map((submission) => [ + , + , + ])} + +
+
+ ); +}; + +SubmissionTable.defaultProps = {}; + +SubmissionTable.propTypes = { + submissions: PT.arrayOf(PT.shape()), + showDetails: PT.shape({}), + track: PT.string, + onDelete: PT.func, + helpPageUrl: PT.string, + onDownload: PT.func, + onShowDetails: PT.func, + status: PT.string, + submissionPhaseStartDate: PT.string, +}; + +export default SubmissionTable; diff --git a/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/styles.scss b/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/styles.scss new file mode 100644 index 0000000..7dd8e26 --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/SubmissionTable/styles.scss @@ -0,0 +1,151 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$status-space-10: $base-unit * 2; +$status-space-20: $base-unit * 4; +$status-space-25: $base-unit * 5; +$submission-space-10: $base-unit * 2; +$submission-space-20: $base-unit * 4; +$submission-space-25: $base-unit * 5; +$submission-space-50: $base-unit * 10; + +.submissions-table { + border: 1px solid $tc-gray-10; + overflow: hidden; + border-radius: 4px 4px 0 0; + + @include xs-to-sm { + border: none; + border-radius: 0; + border-top: 1px solid $tc-gray-10; + border-bottom: 1px solid $tc-gray-10; + } + + table { + width: 100%; + + thead { + tr { + background: $tc-gray-neutral-light; + } + } + + th { + font-size: 13px; + color: $tc-gray-50; + font-weight: 500; + line-height: $status-space-20; + text-align: left; + padding: $status-space-25 $status-space-20; + + &.status, + &.actions { + text-align: center; + } + + @include xs-to-sm { + display: none; + } + } + + .no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + } + + .status-col { + text-align: center; + } + + .action-col { + text-align: center; + } +} + +.submission-row { + width: 100%; + font-size: 15px; + color: $tc-black; + font-weight: 400; + + td { + vertical-align: middle; + padding: $submission-space-20; + background: $tc-white; + border-top: 1px solid $tc-gray-10; + line-height: 12px; + + &.no-submission { + line-height: $submission-space-20; + padding: $submission-space-50 $submission-space-20; + text-align: center; + } + + &.dev-details { + background: $tc-gray-neutral-light; + padding-right: 60px; + } + } + + .id-col { + font-weight: 700; + } + + .date-col { + color: $tc-gray-50; + font-weight: 400; + line-height: $submission-space-20; + } + + .status-col { + text-align: center; + + button { + background: none; + border: none; + padding: 0; + + .pending { + text-transform: initial; + font-size: 15px; + color: $tc-gray-40; + line-height: $submission-space-20; + } + } + } + + .action-col { + text-align: center; + + .delete-icon { + margin: 0 $submission-space-25; + } + + button { + background: none; + border: 0; + font-size: 0; + padding: 0; + line-height: 0; + display: inline-block; + + &:focus { + outline: none; + } + } + + .expand-icon { + transition: all 1.5s; + + &.expanded { + transform: rotate(180deg); + } + } + } + + .status-col button:focus { + outline: none; + } +} diff --git a/src/containers/SubmissionManagement/MySubmissions/index.jsx b/src/containers/SubmissionManagement/MySubmissions/index.jsx new file mode 100644 index 0000000..7b21c7f --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/index.jsx @@ -0,0 +1,159 @@ +import React from "react"; +import PT from "prop-types"; +import moment from "moment"; +import { PrimaryButton } from "components/Buttons"; +import SubmissionTable from "./SubmissionTable"; +import LoadingIndicator from "components/LoadingIndicator"; +import * as util from "../../../utils/challenge"; +import config from "../../../../config"; + +import styles from "./styles.scss"; + +const MySubmissions = ({ + challengeId, + challengeTrack, + challengeName, + challengeStatus, + challengePhases, + submissions, + loadingSubmissions, + showDetails, + submissionPhaseStartDate, + onShowDetails, + onDelete, + onDownload, + helpPageUrl, + isDeletingSubmission, +}) => { + const challengeType = challengeTrack.toLowerCase(); + const isDesign = challengeType === "design"; + const isDevelop = challengeType === "development"; + const currentPhase = util.currentPhase(challengePhases); + const submissionPhase = util.submissionPhase(challengePhases); + const submissionEndDate = + submissionPhase && util.phaseEndDate(submissionPhase); + + const now = moment(); + const end = moment(currentPhase && currentPhase.scheduledEndDate); + const diff = end.isAfter(now) ? end.diff(now) : 0; + const timeLeft = moment.duration(diff); + + const [days, hours, minutes] = [ + timeLeft.get("days"), + timeLeft.get("hours"), + timeLeft.get("minutes"), + ]; + + const isLoadingOrDeleting = loadingSubmissions || isDeletingSubmission; + + return ( +
+ {/* Header */} +
+
+

{challengeName}

+ + < Back + +
+
+ {currentPhase &&

{currentPhase.name}

} + {challengeStatus !== "Completed" ? ( +
+

+ {days > 0 && `${days}D`} {hours}H {minutes}M +

+

left

+
+ ) : ( +

The challenge has ended

+ )} +
+
+ {/* Table */} +
+
+

Manage your submissions

+ {isDesign && currentPhase && ( +

+ {currentPhase.name} Ends:{" "} + {end.format("dddd MM/DD/YY hh:mm A")} +

+ )} +
+ {isDesign && ( +

+ We always recommend to download your submission to check you + uploaded the correct zip files and also verify the photos and fonts + declarations. If you don’t want to see a submission, simply delete. + If you have a new submission, use the Upload Submission button to + add one at the top of the list. +

+ )} + {isDevelop && ( +

+ We always recommend to download your submission to check you + uploaded the correct zip file. If you don’t want to see the + submission, simply delete. If you have a new submission, use the + Upload Submission button to overwrite the current one. +

+ )} + {isLoadingOrDeleting && } + {!isLoadingOrDeleting && ( + + onDownload(challengeType, submissionId) + } + onShowDetails={onShowDetails} + /> + )} +
+ {/* Footer */} + {now.isBefore(submissionEndDate) && ( +
+ + {!isDevelop || !submissions || submissions.length === 0 + ? "Add Submission" + : "Update Submission"} + +
+ )} +
+ ); +}; + +MySubmissions.defaultProps = {}; + +MySubmissions.propTypes = { + challengeId: PT.string, + challengeTrack: PT.string, + challengeName: PT.string, + challengeStatus: PT.string, + challengePhases: PT.arrayOf(PT.shape()), + submissions: PT.arrayOf(PT.shape()), + loadingSubmissions: PT.bool, + showDetails: PT.shape({}), + submissionPhaseStartDate: PT.string, + onShowDetails: PT.func, + onDelete: PT.func, + onDownload: PT.func, + helpPageUrl: PT.string, + isDeletingSubmission: PT.bool, +}; + +export default MySubmissions; diff --git a/src/containers/SubmissionManagement/MySubmissions/styles.scss b/src/containers/SubmissionManagement/MySubmissions/styles.scss new file mode 100644 index 0000000..7fd5c7c --- /dev/null +++ b/src/containers/SubmissionManagement/MySubmissions/styles.scss @@ -0,0 +1,184 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$submang-space-140: $base-unit * 28; +$submang-space-50: $base-unit * 10; +$submang-space-35: $base-unit * 7; +$submang-space-30: $base-unit * 6; +$submang-space-25: $base-unit * 5; +$submang-space-20: $base-unit * 4; +$gray-color: $tc-gray-40; +$light-gray-color: $tc-gray-neutral-light; + +.btn-wrap { + text-align: center; + color: white; +} + +.add-sub-btn { + margin: 10px auto; +} + +.submission-management { + padding-bottom: 40px; +} + +.submission-management-content { + padding: $submang-space-20 $submang-space-140; + + @include md { + padding: $submang-space-20 $submang-space-35; + } + + @include xs-to-sm { + padding: 0; + } + + .content-head { + display: flex; + justify-content: space-between; + font-weight: 400; + font-size: 15px; + color: $tc-gray-50; + line-height: $submang-space-25; + margin-top: $submang-space-50; + margin-bottom: $base-unit; + + @include xs-to-sm { + flex-direction: column; + margin-top: $submang-space-20; + padding: 0 15px; + } + + .title { + font-size: 20px; + color: $tc-gray-80; + line-height: $submang-space-30; + } + + .round-ends { + font-size: 15px; + color: $tc-black; + line-height: $submang-space-30; + + @include xs-to-sm { + margin-top: 10px; + margin-bottom: 15px; + } + + span.ends-label { + color: $tc-gray-50; + + @include xs-to-sm { + display: block; + margin-bottom: -10px; + } + } + } + } + + .recommend-info { + font-weight: 400; + color: $tc-gray-50; + line-height: $submang-space-25; + margin-bottom: $submang-space-30; + + @include xs-to-sm { + padding: 0 15px; + } + } +} + +.submission-management-header { + display: flex; + justify-content: space-between; + width: 100%; + font-weight: 400; + padding: $submang-space-25 $submang-space-50; + background: $light-gray-color; + border-bottom: 1px solid $tc-gray-neutral-dark; + + @include md { + padding: $submang-space-25 $submang-space-35; + } + + @include xs-to-sm { + flex-direction: column; + padding: $submang-space-20 15px; + } + + .left-col { + padding-right: 200px; + + @include xs-to-sm { + padding-right: 0; + } + + .name { + font-size: 28px; + color: $tc-black; + line-height: $submang-space-35; + } + + .back-btn { + background: none; + color: $gray-color; + font-size: 20px; + line-height: $submang-space-25; + margin-top: 5px; + display: inline-block; + padding: 0; + border: none; + font-weight: 400; + text-transform: initial; + + &:focus { + outline: none; + } + } + } + + .right-col { + @include roboto-regular; + + min-width: 100px; + + @include xs-to-sm { + margin-top: 10px; + + p, + div { + display: inline-block; + } + } + + .round { + font-size: 15px; + color: $tc-gray-80; + line-height: $submang-space-25; + } + + .time-left { + font-size: 20px; + color: $tc-gray-80; + line-height: $submang-space-30; + + @include xs-to-sm { + font-size: 15px; + font-weight: bold; + line-height: $submang-space-25; + margin-left: 5px; + } + } + + .left-label { + font-size: 13px; + color: $tc-gray-50; + line-height: $submang-space-20; + + @include xs-to-sm { + margin-left: 5px; + } + } + } +} diff --git a/src/containers/SubmissionManagement/index.jsx b/src/containers/SubmissionManagement/index.jsx new file mode 100644 index 0000000..a582958 --- /dev/null +++ b/src/containers/SubmissionManagement/index.jsx @@ -0,0 +1,288 @@ +import React, { useEffect, useLayoutEffect, useRef } from "react"; +import PT from "prop-types"; +import _ from "lodash"; +import { navigate } from "@reach/router"; +import { connect } from "react-redux"; +import Button from "components/Buttons"; +import Modal from "components/Modal"; +import LoadingIndicator from "components/LoadingIndicator"; +import MySubmissions from "./MySubmissions"; +import AccessDenied from "components/AccessDenied"; +import { ACCESS_DENIED_REASON, CHALLENGES_URL } from "../../constants"; +import actions from "../../actions"; +import { isLegacyId, isUuid } from "../../utils/challenge"; + +import "./styles.scss"; + +const SubmissionManagement = ({ + id, + challengeId, + challengeLegacyId, + challengeTrack, + challengeName, + challengeStatus, + challengePhases, + + isDeletingSubmission, + isLoadingChallenge, + isChallengeLoaded, + isLoadingMySubmissions, + isRegistered, + + mySubmissions, + submissionPhaseStartDate, + showDetails, + showModal, + toBeDeletedId, + + onShowDetails, + onSubmissionDelete, + onCancelSubmissionDelete, + onSubmissionDeleteConfirmed, + onDownloadSubmission, + getChallenge, + getMySubmissions, +}) => { + const propsRef = useRef(); + propsRef.current = { + id, + challengeId, + challengeLegacyId, + getChallenge, + getMySubmissions, + }; + + useLayoutEffect(() => { + const didChallengeLoaded = + propsRef.current.challengeId && + `${propsRef.current.challengeId}` === `${propsRef.current.id}`; + if (didChallengeLoaded) { + propsRef.current.getMySubmissions(propsRef.current.id); + return; + } + + if (isLegacyId(propsRef.current.id)) { + propsRef.current.getChallenge(propsRef.current.id); + } else if (isUuid(propsRef.current.id)) { + propsRef.current.getChallenge(propsRef.current.id); + propsRef.current.getMySubmissions(propsRef.current.id); + } else { + navigate(CHALLENGES_URL); + } + }, []); + + useEffect(() => { + if (isChallengeLoaded && isLegacyId(propsRef.current.id)) { + navigate( + `${CHALLENGES_URL}/${propsRef.current.challengeId}/my-submissions` + ); + propsRef.current.getMySubmissions(propsRef.current.challengeId); + } + }, [isChallengeLoaded]); + + if (isLoadingChallenge) { + return ; + } + + if (!isChallengeLoaded) { + return null; + } + + if (!isRegistered) { + return ( + + ); + } + + const isEmpty = _.isEmpty(challengeName); + const modal = ( + +
+

+ Are you sure you want to delete submission{" "} + {toBeDeletedId}? +

+

+ This will permanently remove all files from our servers and can’t be + undone. You’ll have to upload all the files again in order to restore + it. +

+
+ +
+
+ + +
+
+
+ ); + + return ( +
+
+ {!isEmpty && ( + + )} + {showModal && modal} +
+
+ ); +}; + +SubmissionManagement.defaultProps = {}; + +SubmissionManagement.propTypes = { + id: PT.string, + challengeId: PT.string, + challengeLegacyId: PT.number, + challengeTrack: PT.string, + challengeName: PT.string, + challengeStatus: PT.string, + challengePhases: PT.arrayOf(PT.shape()), + + isDeletingSubmission: PT.bool, + isLoadingChallenge: PT.bool, + isChallengeLoaded: PT.bool, + isLoadingMySubmissions: PT.bool, + isRegistered: PT.bool, + + mySubmissions: PT.arrayOf(PT.shape()), + submissionPhaseStartDate: PT.string, + showDetails: PT.shape({}), + showModal: PT.bool, + toBeDeletedId: PT.string, + + onShowDetails: PT.func, + onSubmissionDelete: PT.func, + onCancelSubmissionDelete: PT.func, + onSubmissionDeleteConfirmed: PT.func, + onDownloadSubmission: PT.func, + getChallenge: PT.func, + getMySubmissions: PT.func, +}; + +const mapStateToProps = (state, ownProps) => { + const challenge = (state.challenge && state.challenge.challenge) || {}; + const allPhases = challenge.phases || []; + const submissionPhase = + allPhases.find( + (phase) => + ["Submission", "Checkpoint Submission"].includes(phase.name) && + phase.isOpen + ) || {}; + + return { + id: ownProps.challengeId, + challengeId: challenge.id, + challengeLegacyId: challenge.legacyId, + challengeTrack: challenge.track, + challengeName: challenge.name, + challengeStatus: challenge.status, + challengePhases: challenge.phases, + + isDeletingSubmission: state.submissionManagement.deletingSubmission, + isLoadingChallenge: state.challenge.isLoadingChallenge, + isChallengeLoaded: state.challenge.isChallengeLoaded, + isLoadingMySubmissions: state.submissionManagement.isLoadingMySubmissions, + isRegistered: challenge.isRegistered, + + mySubmissions: state.submissionManagement.mySubmissions, + submissionPhaseStartDate: + submissionPhase.actualStartDate || + submissionPhase.scheduledStartDate || + "", + showDetails: state.submissionManagement.showDetails, + showModal: state.submissionManagement.showModal, + toBeDeletedId: state.submissionManagement.toBeDeletedId, + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onShowDetails: (submissionId) => { + dispatch( + actions.submissionManagement.mySubmissions.showDetails(submissionId) + ); + }, + onSubmissionDelete: (submissionId) => { + dispatch( + actions.submissionManagement.mySubmissions.confirmDelete(submissionId) + ); + }, + onCancelSubmissionDelete: () => { + dispatch(actions.submissionManagement.mySubmissions.cancelDelete()); + }, + onSubmissionDeleteConfirmed: (submissionId) => { + dispatch( + actions.submissionManagement.mySubmissions.deleteSubmissionInit() + ); + dispatch( + actions.submissionManagement.mySubmissions.deleteSubmissionDone( + submissionId + ) + ); + }, + onDownloadSubmission: (challengeType, submissionId) => { + dispatch( + actions.submissionManagement.mySubmissions.downloadSubmissionDone( + challengeType, + submissionId + ) + ); + }, + getChallenge: (challengeId) => { + dispatch(actions.challenge.getChallengeInit()); + dispatch(actions.challenge.getChallengeDone(challengeId)); + }, + getMySubmissions: (challengeId) => { + dispatch( + actions.submissionManagement.mySubmissions.getMySubmissionsInit() + ); + dispatch( + actions.submissionManagement.mySubmissions.getMySubmissionsDone( + challengeId + ) + ); + }, + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(SubmissionManagement); diff --git a/src/containers/SubmissionManagement/styles.scss b/src/containers/SubmissionManagement/styles.scss new file mode 100644 index 0000000..2a0b498 --- /dev/null +++ b/src/containers/SubmissionManagement/styles.scss @@ -0,0 +1,76 @@ +@import '~styles/variables'; +@import '~styles/mixins'; + +$sm-space-10: $base-unit * 2; +$sm-space-15: $base-unit * 3; +$sm-space-25: $base-unit * 5; +$sm-space-40: $base-unit * 8; + +.outer-container { + background: $tc-gray-10; + padding: 24px; + max-width: 2056px; + margin: 0 auto; + + @include xs-to-sm { + padding: 15px; + } +} + +.deletingIndicator { + margin: -17px 0; + + &:global.hidden { + display: none; + } +} + +.submission-management-container { + @include roboto-regular; + + background: #fff; + margin: auto; + min-height: calc(100vh - var(--navbarHeight, 60px) - 24px * 2); + +} + +.modal-content { + @include roboto-regular; + + text-align: center; + padding-top: $sm-space-15; + padding-bottom: $sm-space-15; + + .are-you-sure { + font-weight: 400; + font-size: 15px; + color: $tc-gray-80; + line-height: $sm-space-25; + margin-bottom: $sm-space-10; + padding: 0 $sm-space-15; + + .id { + color: #000; + font-weight: 500; + } + } + + .remove-warn { + font-weight: 400; + font-size: 13px; + color: $tc-gray-60; + line-height: $sm-space-25; + padding: 0 $sm-space-15; + margin-bottom: $sm-space-40; + } + + .action-btns { + button { + margin: 0 5px; + } + + &:global.hidden { + display: none; + } + } +} diff --git a/src/containers/challenge-detail/index.jsx b/src/containers/challenge-detail/index.jsx index 7773c9d..b2a17b1 100644 --- a/src/containers/challenge-detail/index.jsx +++ b/src/containers/challenge-detail/index.jsx @@ -227,7 +227,7 @@ class ChallengeDetailPageContainer extends React.Component { challenge.isLegacyChallenge && !history.location.pathname.includes(challenge.id) ) { - history.location.pathname = `/challenges/${challenge.id}`; // eslint-disable-line no-param-reassign + history.location.pathname = `/earn/find/challenges/${challenge.id}`; // eslint-disable-line no-param-reassign history.push(history.location.pathname, history.state); } diff --git a/src/reducers/auth.js b/src/reducers/auth.js index 6e89996..5b36096 100644 --- a/src/reducers/auth.js +++ b/src/reducers/auth.js @@ -30,6 +30,10 @@ function onProfileLoaded(state, action) { }; } +function onSetAuthDone(state, { payload }) { + return { ...state, user: payload, isAuthInitialized: true }; +} + /** * Creates a new Auth reducer with the specified initial state. * @param {Object} initialState Optional. Initial state. @@ -58,6 +62,7 @@ function create(initialState) { }), }, }), + [actions.auth.setAuthDone]: onSetAuthDone, }, _.defaults(initialState, { authenticating: true, @@ -65,6 +70,7 @@ function create(initialState) { tokenV2: "", tokenV3: "", user: null, + isAuthInitialized: false, }) ); } diff --git a/src/reducers/challenge.js b/src/reducers/challenge.js index c2cb8c9..346db3e 100644 --- a/src/reducers/challenge.js +++ b/src/reducers/challenge.js @@ -446,6 +446,32 @@ function onGetSubmissionInformationDone(state, action) { }; } +function onGetChallengeInit(state) { + return { + ...state, + isLoadingChallenge: true, + isChallengeLoaded: false, + }; +} + +function onGetChallengeDone(state, { error, payload }) { + if (error) { + logger.error("Failed to get challenge details!", payload); + fireErrorMessage( + "ERROR: Failed to load the challenge", + "Please, try again a bit later" + ); + return { ...state, isLoadingChallenge: false, isChallengeLoaded: false }; + } + + return { + ...state, + challenge: { ...payload }, + isLoadingChallenge: false, + isChallengeLoaded: true, + }; +} + /** * Creates a new Challenge reducer with the specified initial state. * @param {Object} initialState Optional. Initial state. @@ -492,6 +518,8 @@ function create(initialState) { [a.getActiveChallengesCountDone]: onGetActiveChallengesCountDone, [a.getSubmissionInformationInit]: onGetSubmissionInformationInit, [a.getSubmissionInformationDone]: onGetSubmissionInformationDone, + [a.getChallengeInit]: onGetChallengeInit, + [a.getChallengeDone]: onGetChallengeDone, }, _.defaults(initialState, { details: null, @@ -510,6 +538,7 @@ function create(initialState) { updatingChallengeUuid: "", mmSubmissions: [], submissionInformation: null, + isLoadingChallenge: false, }) ); } diff --git a/src/reducers/index.js b/src/reducers/index.js index d096045..e3bb5a5 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,8 @@ import page from "./page"; import terms from "./terms"; import auth from "./auth"; import errors from "./errors"; +import submission from "./submission"; +import submissionManagement from "./submissionManagement"; export default combineReducers({ challenges, @@ -20,5 +22,7 @@ export default combineReducers({ page, terms, auth, + submission, + submissionManagement, errors, }); diff --git a/src/reducers/lookup.js b/src/reducers/lookup.js index e384553..56d9523 100644 --- a/src/reducers/lookup.js +++ b/src/reducers/lookup.js @@ -15,8 +15,18 @@ function onGetTagsDone(state, { payload }) { return { ...state, tags: payload }; } -function onGetCommunityListDone(state, { payload }) { - return { ...state, subCommunities: payload }; +function onGetCommunityListInit(state) { + return { + ...state, + isSubCommunitiesLoaded: false, + }; +} + +function onGetCommunityListDone(state, { error, payload }) { + if (error) { + return { ...state, subCommunities: [] }; + } + return { ...state, subCommunities: payload, isSubCommunitiesLoaded: true }; } /** @@ -259,6 +269,7 @@ function create(initialState = {}) { return handleActions( { [a.getTagsDone]: onGetTagsDone, + [a.getCommunityListInit]: onGetCommunityListInit, [a.getCommunityListDone]: onGetCommunityListDone, [a.getTypesInit]: (state) => state, [a.getTypesDone]: onGetTypesDone, @@ -295,6 +306,7 @@ function create(initialState = {}) { tags: [], subCommunities: [], isLoggedIn: null, + isSubCommunitiesLoaded: false, }) ); } diff --git a/src/reducers/submission.js b/src/reducers/submission.js new file mode 100644 index 0000000..9496a95 --- /dev/null +++ b/src/reducers/submission.js @@ -0,0 +1,170 @@ +import { handleActions } from "redux-actions"; +import { logger, fireErrorMessage } from "../utils/logger"; + +const defaultState = { + isSubmitting: false, + submitDone: false, + submitErrorMsg: "", + agreed: false, + uploadProgress: 0, + filePickers: [], + submissionFilestackData: { + challengeId: "", + fileUrl: "", + filename: "", + mimetype: "", + size: 0, + key: "", + container: "", + }, +}; + +function onSubmitDone(state, { error, payload }) { + if (error) { + logger.error("Failed to submit for the challenge"); + fireErrorMessage( + "ERROR: Failed to submit!", + "Please, try to submit from https://software.topcoder.com or email you submission to support@topcoder.com" + ); + + return { + ...state, + submitErrorMsg: "Failed to submit", + isSubmitting: false, + submitDone: false, + }; + } + + if (payload.message) { + /* payload message is present when upload of file fails due to any reason so + * handle this special case for error */ + logger.error(`Failed to submit for the challenge - ${payload.message}`); + return { + ...state, + submitErrorMsg: payload.message || "Failed to submit", + isSubmitting: false, + submitDone: false, + }; + } + + /* TODO: I am not sure, whether this code is just wrong, or does it handle + * only specific errors, returned from API for design submissions? I am + * adding a more generic failure handling code just above. */ + if (payload.result && !payload.result.success) { + return { + ...state, + submitErrorMsg: payload.result.content.message || "Failed to submit", + isSubmitting: false, + submitDone: false, + }; + } + + return { + ...state, + ...payload, + isSubmitting: false, + submitDone: true, + }; +} + +function onSubmitInit(state) { + return { + ...state, + isSubmitting: true, + submitDone: false, + submitErrorMsg: "", + uploadProgress: 0, + }; +} + +function onSubmitReset(state) { + return { + ...state, + isSubmitting: false, + submitDone: false, + submitErrorMsg: "", + uploadProgress: 0, + }; +} + +function onUploadProgress(state, { payload }) { + return { + ...state, + uploadProgress: payload, + }; +} + +/** + * Returns a new state with the filePicker updated according to map, or added if not existing + * @param {Object} state Current state + * @param {String} id ID of the + * @param {Object} map Key value pairs for the new FilePicker state + * @return New state + */ +function fpSet(state, id, map) { + let found = false; + + const newFilePickers = state.filePickers.map((fp) => { + if (fp.id === id) { + found = true; + return { + ...fp, + ...map, + }; + } + return fp; + }); + + if (found) { + return { ...state, filePickers: newFilePickers }; + } + + return { + ...state, + filePickers: [...newFilePickers, { id, ...map }], + }; +} + +function onSetAgreed(state, { payload }) { + return { ...state, agreed: payload }; +} + +function onSetFilePickerError(state, { payload }) { + return fpSet(state, payload.id, { error: payload.error }); +} + +function onSetFilePickerFileName(state, { payload }) { + return fpSet(state, payload.id, { fileName: payload.fileName }); +} + +function onSetFilePickerUploadProgress(state, { payload }) { + return fpSet(state, payload.id, { uploadProgress: payload.progress }); +} + +function onSetFilePickerDragged(state, { payload }) { + return fpSet(state, payload.id, { dragged: payload.dragged }); +} + +function onSetSubmissionFilestackData(state, { payload }) { + return { ...state, submissionFilestackData: payload }; +} + +const reducer = handleActions( + { + SUBMIT: { + SUBMIT_DONE: onSubmitDone, + SUBMIT_INIT: onSubmitInit, + SUBMIT_RESET: onSubmitReset, + UPLOAD_PROGRESS: onUploadProgress, + SET_AGREED: onSetAgreed, + SET_FILE_PICKER_ERROR: onSetFilePickerError, + SET_FILE_PICKER_FILE_NAME: onSetFilePickerFileName, + SET_FILE_PICKER_UPLOAD_PROGRESS: onSetFilePickerUploadProgress, + SET_FILE_PICKER_DRAGGED: onSetFilePickerDragged, + SET_SUBMISSION_FILESTACK_DATA: onSetSubmissionFilestackData, + }, + }, + defaultState +); + +export default reducer; diff --git a/src/reducers/submissionManagement.js b/src/reducers/submissionManagement.js new file mode 100644 index 0000000..29393dd --- /dev/null +++ b/src/reducers/submissionManagement.js @@ -0,0 +1,104 @@ +import { handleActions } from "redux-actions"; +import _ from "lodash"; +import { logger } from "../utils/logger"; + +const defaultState = { + showDetails: {}, + showModal: false, + toBeDeletedId: "", + deletingSubmission: false, + mySubmissions: [], + isLoadingMySubmissions: false, +}; + +function onShowDetails(state, { payload: id }) { + const showDetails = _.clone(state.showDetails); + if (showDetails[id]) delete showDetails[id]; + else showDetails[id] = true; + return { ...state, showDetails }; +} + +function onConfirmDelete(state, { payload }) { + return { + ...state, + showModal: true, + toBeDeletedId: payload, + }; +} + +function onCancelDelete(state) { + return { + ...state, + showModal: false, + toBeDeletedId: "", + }; +} + +function onDeleteSubmissionInit(state) { + return { + ...state, + showModal: false, + deletingSubmission: true, + }; +} + +function onDeleteSubmissionDone(state, { error, payload }) { + if (error) { + return { + ...state, + deletingSubmission: false, + }; + } + + const deletedSubmissionId = payload; + return { + ...state, + deletingSubmission: false, + showModal: false, + toBeDeletedId: "", + mySubmissions: state.mySubmissions.filter( + (submission) => submission.id !== deletedSubmissionId + ), + }; +} + +function onGetMySubmissionsInit(state) { + return { + ...state, + isLoadingMySubmissions: true, + }; +} + +function onGetMySubmissionsDone(state, { error, payload }) { + if (error) { + logger.error("Failed to get user's submissions for the challenge", payload); + return { + ...state, + mySubmissions: [], + isLoadingMySubmissions: false, + }; + } + + return { + ...state, + mySubmissions: [...payload], + isLoadingMySubmissions: false, + }; +} + +const reducer = handleActions( + { + MY_SUBMISSIONS: { + SHOW_DETAILS: onShowDetails, + CONFIRM_DELETE: onConfirmDelete, + CANCEL_DELETE: onCancelDelete, + DELETE_SUBMISSION_INIT: onDeleteSubmissionInit, + DELETE_SUBMISSION_DONE: onDeleteSubmissionDone, + GET_MY_SUBMISSIONS_INIT: onGetMySubmissionsInit, + GET_MY_SUBMISSIONS_DONE: onGetMySubmissionsDone, + }, + }, + defaultState +); + +export default reducer; diff --git a/src/routers/submissions/index.jsx b/src/routers/submissions/index.jsx new file mode 100644 index 0000000..e68f4c8 --- /dev/null +++ b/src/routers/submissions/index.jsx @@ -0,0 +1,38 @@ +/** + * Main App component + */ +import React from "react"; +import { Router } from "@reach/router"; +import _ from "lodash"; +import Submission from "../../containers/Submission"; +import SubmissionManagement from "../../containers/SubmissionManagement"; +import ErrorMessage from "components/ErrorMessage"; +import { useSelector } from "react-redux"; +import { clearErrorMesssage } from "../../utils/logger"; +import { CHALLENGES_URL } from "../../constants"; + +import "react-date-range/dist/theme/default.css"; +import "react-date-range/dist/styles.css"; +import "rc-tooltip/assets/bootstrap.css"; + +const App = () => { + const alert = useSelector((state) => state.errors.alerts[0]); + return ( + <> + + + + +
+ {alert && ( + clearErrorMesssage()} + /> + )} + + ); +}; + +export default App; diff --git a/src/services/api.js b/src/services/api.js index 76d05de..dc85cbd 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,6 +1,6 @@ /* global process */ import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; -import { keys } from "lodash"; +import _, { keys } from "lodash"; import * as utils from "../utils"; async function doFetch(endpoint, options = {}, v3, baseUrl) { @@ -25,6 +25,16 @@ async function doFetch(endpoint, options = {}, v3, baseUrl) { }); } +async function download(endpoint, baseUrl, cancellationSignal) { + const options = { + headers: { ["Content-Type"]: "application/json" }, + signal: cancellationSignal, + }; + const response = await doFetch(endpoint, options, undefined, baseUrl); + + return response; +} + async function get(endpoint, baseUrl, cancellationSignal) { const options = { headers: { ["Content-Type"]: "application/json" }, @@ -69,10 +79,44 @@ async function patch(endpoint, body) { return response.json(); } +/** + * Upload with progress + * @param {String} endpoint + * @param {Object} body and headers + * @param {Function} onProgress handler for update progress only works for client side for now + * @return {Promise} + */ +async function upload(endpoint, options, onProgress) { + const base = process.env.API.V5; + const { tokenV3 } = await getAuthUserTokens(); + const headers = options.headers ? _.clone(options.headers) : {}; + if (tokenV3) headers.Authorization = `Bearer ${tokenV3}`; + + return new Promise((res, rej) => { + const xhr = new XMLHttpRequest(); //eslint-disable-line + xhr.open(options.method, `${base}${endpoint}`); + Object.keys(headers).forEach((key) => { + if (headers[key] != null) { + xhr.setRequestHeader(key, headers[key]); + } + }); + xhr.onload = (e) => res(e.target.responseText); + xhr.onerror = rej; + if (xhr.upload && onProgress) { + xhr.upload.onprogress = (evt) => { + if (evt.lengthComputable) onProgress(evt.loaded / evt.total); + }; + } + xhr.send(options.body); + }); +} + export default { doFetch, get, post, put, patch, + upload, + download, }; diff --git a/src/services/challenge.js b/src/services/challenge.js new file mode 100644 index 0000000..38e4c6b --- /dev/null +++ b/src/services/challenge.js @@ -0,0 +1,116 @@ +import api from "./api"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; +import { decodeToken } from "tc-auth-lib"; +import qs from "qs"; +import _ from "lodash"; +import * as util from "../utils/api"; + +/** + * @internal + */ +async function getChallengeDetails(endpoint, legacyInfo) { + let query = ""; + if (legacyInfo) { + query = `legacyId=${legacyInfo.legacyId}`; + } + + const url = `${endpoint}?${query}`; + const result = await api.get(url).then(util.tryThrowError); + + return { + challenge: legacyInfo ? result[0] : result, + }; +} + +/** + * Gets challenge registrants from Topcoder API. + * @param {Number|String} challengeId + * @return {Promise} Resolves to the challenge registrants array. + * @internal + */ +async function getChallengeRegistrants(challengeId) { + /* If no token provided, resource will return Submitter role only */ + const roleId = (await isLoggedIn()) + ? await getRoleId("Submitter") + : undefined; + const params = { + challengeId, + roleId, + }; + + let registrants = await api + .get(`/resources?${qs.stringify(params)}`) + .then(util.tryThrowError); + + /* API will return all roles to currentUser, so need to filter in FE */ + if (roleId) { + registrants = _.filter(registrants, (r) => r.roleId === roleId); + } + + return registrants || []; +} + +/** + * @internal + */ +async function isLoggedIn() { + const { tokenV3 } = await getAuthUserTokens(); + return !!tokenV3; +} + +/** + * Get the Resource Role ID from provided Role Name + * @param {String} roleName + * @return {Promise} + * @internal + */ +async function getRoleId(roleName) { + const params = { + name: roleName, + isActive: true, + }; + const roles = await api + .get(`/resource-roles?${qs.stringify(params)}`) + .then(util.tryThrowError); + + if (_.isEmpty(roles)) { + throw new Error("Resource Role not found!"); + } + + return roles[0].id; +} + +async function getChallenge(challengeId) { + let challenge = {}; + let isLegacyChallenge = false; + let isRegistered = false; + let registrants = []; + const { tokenV3 } = await getAuthUserTokens(); + const memberId = tokenV3 ? decodeToken(tokenV3).userId : null; + + if (/^[\d]{5,8}$/.test(challengeId)) { + isLegacyChallenge = true; + challenge = await getChallengeDetails("/challenges/", { + legacyId: challengeId, + }).then((res) => res.challenge); + } else { + challenge = await getChallengeDetails(`/challenges/${challengeId}`).then( + (res) => res.challenge + ); + } + + if (challenge) { + registrants = await getChallengeRegistrants(challenge.id); + isRegistered = + memberId && _.some(registrants, (r) => `${r.memberId}` === `${memberId}`); + } + + return { + ...challenge, + isRegistered, + }; +} + +export default { + getChallenge, +}; diff --git a/src/services/submission.js b/src/services/submission.js new file mode 100644 index 0000000..a67dd64 --- /dev/null +++ b/src/services/submission.js @@ -0,0 +1,57 @@ +import qs from "qs"; +import _ from "lodash"; +import api from "./api"; + +import * as util from "../utils/api"; + +async function submit(data, onProgress) { + const url = "/submissions/"; + return api + .upload( + url, + { + body: data, + method: "POST", + }, + onProgress + ) + .then( + (res) => { + const jres = JSON.parse(res); + return jres; + }, + (err) => { + throw err; + } + ); +} + +function deleteSubmission(submissionId) { + return api + .delete(`/submissions/${submissionId}`) + .then(util.tryThrowError) + .then(() => submissionId); +} + +function downloadSubmission(track, submissionId) { + return api + .download(`/submissions/${submissionId}/download`) + .then(util.tryThrowError) + .then((res) => { + return res.blob(); + }); +} + +function getSubmissions(filter) { + return api + .get(`/submissions?${qs.stringify(filter, { encode: false })}`) + .then(util.tryThrowError) + .then((res) => res); +} + +export default { + submit, + deleteSubmission, + downloadSubmission, + getSubmissions, +}; diff --git a/src/styles/_legacy-buttons.scss b/src/styles/_legacy-buttons.scss new file mode 100644 index 0000000..4862526 --- /dev/null +++ b/src/styles/_legacy-buttons.scss @@ -0,0 +1,221 @@ +/* LEGACY CODE BELOW */ + +// Buttons + +// Table of Contents +// +// Links +// Buttons + +:global { + button { + cursor: pointer; + } + + // Links + a.tc-link, + .tc-link, + a.tc-link:active, + .tc-link:active, + a.tc-link:visited, + .tc-link:visited { + cursor: pointer; + } + + a.tc-link:hover, + .tc-link:hover { + color: $tc-dark-blue; + text-decoration: none; + } + + .tc-btn { + @include roboto-regular; + + border-radius: $corner-radius; + cursor: pointer; + user-select: none; + + &:focus { + outline: none; + } + } + + // Button States + + a.tc-btn-pressed, + .tc-btn-pressed { + box-shadow: 0 1px 3px 0 $tc-gray-80; + } + + // Button Sizes + + a.tc-btn-lg, + .tc-btn-lg { + height: 50px; + padding: 10px 30px; + + @include tc-heading-md; + } + + a.tc-btn-md, + .tc-btn-md { + height: 40px; + padding: 10px 25px; + + @include tc-label-lg; + } + + a.tc-btn-sm, + .tc-btn-sm { + height: 30px; + padding: 5px 15px; + + @include tc-label-md; + } + + a.tc-btn-xs, + .tc-btn-xs { + height: 20px; + padding: 3px 10px; + + @include tc-label-sm; + } + + // Button Types + + .tc-btn-primary { + background-color: $tc-dark-blue; + + @include background-gradient($tc-dark-blue, $tc-dark-blue); + + color: $tc-gray-neutral-light; + border: 1px solid $tc-dark-blue; + + @include button-transition; + + &:hover { + color: $tc-gray-neutral-light; + + @include background-gradient($tc-dark-blue, $tc-dark-blue); + } + + &:active { + color: $tc-gray-neutral-light; + + @include background-gradient($tc-dark-blue, $tc-dark-blue); + } + } + + .tc-btn-secondary { + background-color: $tc-gray-40; + color: $tc-gray-neutral-light; + border: 1px solid $tc-gray-50; + + @include button-transition; + + &:hover { + color: $tc-gray-neutral-light; + + @include background-gradient($tc-gray-40, $tc-gray-50); + } + + &:active { + color: $tc-gray-neutral-light; + + @include background-gradient($tc-gray-50, $tc-gray-40); + } + } + + a.tc-btn-default, + .tc-btn-default { + background-color: white; + color: $tc-gray-70; + border: 1px solid $tc-gray-50; + } + + a.tc-btn-warning, + .tc-btn-warning { + background-color: $tc-red-70; + color: $tc-gray-neutral-light; + border: 1px solid $tc-red; + } + + button[disabled], + a.tc-btn[disabled], + button[disabled]:hover, + a.tc-btn[disabled]:hover, + button[disabled]:active, + a.tc-btn[disabled]:active { + background-color: $tc-gray-10; + border: none; + cursor: default; + color: $tc-white; + } + + .tc-outline-btn { + border: 1px solid $tc-gray-30; + background: $tc-white; + cursor: pointer; + display: inline-block; + padding: $base-unit - 1 $base-unit * 2; + font-weight: 400; + font-size: 12px; + color: $tc-black; + line-height: $base-unit * 4; + border-radius: $corner-radius; + margin-left: $base-unit * 3; + } + + .tc-blue-btn { + cursor: pointer; + display: inline-block; + padding: $base-unit - 1 $base-unit * 2; + font-weight: 400; + font-size: 12px; + border-radius: $corner-radius; + margin-left: $base-unit * 3; + background: $tc-dark-blue; + color: $tc-white; + border: none; + line-height: $base-unit * 4 + 2; + } + + .tc-btn.tc-btn-wide { + padding: 0 30px; + } + + .tc-btn.tc-btn-s { + height: 30px; + padding: 0 10px; + line-height: 28px; + font-size: 12px; + font-weight: 500; + + &:active { + line-height: 29px; + } + } + + .tc-btn.tc-btn-ghost { + color: #0096ff; + background-color: $tc-white; + + &:hover { + color: $tc-white; + border-color: #0096ff; + background-color: #0096ff; + } + + &:active { + color: $tc-white; + border-color: #097dce; + background-color: #097dce; + box-shadow: inset 0 1px 1px 0 rgba(0, 0, 0, 0.3); + } + + &:disabled { + border-color: #b7b7b7; + color: #b7b7b7; + } + } +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index c511d50..0adaf13 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -10,6 +10,13 @@ $member-blue: #4c50d9; $member-yellow: #f2c900; $member-red: #ea1900; +// Replace the bourbon background with simple linear gradient - we only use this for the buttons; +@mixin background-gradient($start, $stop) { + background: $start; + background: linear-gradient(to top, $start 0%, $stop 100%); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='$start', endColorstr='$stop', GradientType=0); /* IE6-9 */ +} + // Placeholder @mixin placeholder { &::-webkit-input-placeholder { @@ -28,3 +35,7 @@ $member-red: #ea1900; @content; } } + +@mixin button-transition { + transition: background 0.5s; +} diff --git a/src/styles/_typography.scss b/src/styles/_typography.scss new file mode 100644 index 0000000..21c3fde --- /dev/null +++ b/src/styles/_typography.scss @@ -0,0 +1,143 @@ +// Headings + +h1 { @include tc-heading-xl; } +h2 { @include tc-heading-lg; } +h3 { @include tc-heading-md; } +h4 { @include tc-heading-sm; } +h5 { @include tc-heading-xs; } +h6 { @include tc-heading-xs; } + +h1, +h2, +h3, +h4, +h5, +h6 { color: inherit; } + +/* Label text styles. */ + +@mixin tc-label-xl { + @include roboto-medium; + + color: $tc-black; + font-size: 20px; + line-height: 25px; +} + +@mixin tc-label-lg { + @include roboto-medium; + + color: $tc-black; + font-size: 15px; + line-height: 20px; +} + +@mixin tc-label-md { + @include roboto-medium; + + color: $tc-black; + font-size: 13px; + line-height: 20px; +} + +@mixin tc-label-sm { + @include roboto-regular; + + color: $tc-black; + font-size: 12px; + line-height: 15px; +} + +@mixin tc-label-xs { + @include roboto-regular; + + color: $tc-black; + font-size: 11px; + line-height: 15px; +} + +/* Body text styles. */ + +@mixin tc-body-lg { + @include roboto-regular; + + color: $tc-gray-80; + font-size: 20px; + line-height: 25px; +} + +@mixin tc-body-md { + @include roboto-regular; + + color: $tc-gray-80; + font-size: 15px; + line-height: 25px; +} + +@mixin tc-body-sm { + @include roboto-regular; + + color: $tc-gray-80; + font-size: 13px; + line-height: 25px; +} + +@mixin tc-body-xs { + @include roboto-regular; + + color: $tc-gray-80; + font-size: 11px; + line-height: 20px; +} + +/* Heading text styles. */ + +@mixin tc-heading-xl { + @include roboto-light; + + color: $tc-black; + font-size: 36px; + line-height: 45px; +} + +@mixin tc-heading-lg { + @include roboto-regular; + + color: $tc-black; + font-size: 28px; + line-height: 35px; +} + +@mixin tc-heading-md { + @include roboto-regular; + + color: $tc-black; + font-size: 20px; + line-height: 30px; +} + +@mixin tc-heading-sm { + @include roboto-bold; + + color: $tc-black; + font-size: 15px; + line-height: 25px; +} + +@mixin tc-heading-xs { + @include roboto-bold; + + color: $tc-black; + font-size: 13px; + line-height: 25px; +} + +/* Titles text styles. */ + +@mixin tc-title { + @include roboto-light; + + color: $tc-black; + font-size: 42px; + line-height: 50px; +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 6a8f540..5ca32ec 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -4,6 +4,10 @@ @import "utils"; :global { + :not(.challenge-listing-container) { + @import "legacy-buttons"; + } + html, body { text-rendering: geometricPrecision; @@ -23,4 +27,13 @@ body { flex: 1; } +} + +#tooltips-container-id { + position: fixed; + top: 0; + left: 0; + width: 0; + height: 0; + z-index: 1001; } // end :global diff --git a/src/styles/mixins/_resetChallengeListing.scss b/src/styles/mixins/_resetChallengeListing.scss new file mode 100644 index 0000000..5006894 --- /dev/null +++ b/src/styles/mixins/_resetChallengeListing.scss @@ -0,0 +1,22 @@ +@mixin reset() { + *, + *::before, + *::after { + box-sizing: border-box; + } + + h1 { @include heading-xl; } + h2 { @include heading-lg; } + h3 { @include heading-md; } + h4 { @include heading-sm; } + h5 { @include heading-xs; } + h6 { @include heading-xs; } + + h1,h2,h3,h4,h5,h6 { + line-height: 1; + } + + a { + cursor: pointer; + } +} diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..7b2510e --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,10 @@ +export const tryThrowError = async (res) => { + if (!res.ok) { + // network failure + if (res.statusText) { + throw new Error(res.statusText); + } + } + + return res; +}; diff --git a/src/utils/challenge.js b/src/utils/challenge.js index 3532748..5aca8a4 100644 --- a/src/utils/challenge.js +++ b/src/utils/challenge.js @@ -484,3 +484,18 @@ export function updateChallengeType(challenges, challengeTypeMap) { }); } } + +export const currentPhase = (phases) => { + return phases + .filter((p) => p.name !== "Registration" && p.isOpen) + .sort((a, b) => moment(a.scheduledEndDate).diff(b.scheduledEndDate))[0]; +}; + +export const submissionPhase = (phases) => { + return phases.filter((p) => p.name === "Submission")[0]; +}; + +export const isLegacyId = (id) => /^[\d]{5,8}$/.test(id); + +export const isUuid = (id) => + /^[\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12}|\d{5,8}$/.test(id); diff --git a/src/utils/index.js b/src/utils/index.js index f8cbb73..b3fc4e1 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -169,3 +169,13 @@ export function parseTotalPrizes(s) { } if (valid) return n; } + +export function triggerDownload(fileName,blob) { + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.parentNode.removeChild(link); +} diff --git a/src/utils/logger.js b/src/utils/logger.js index 56f89c7..0f7a6c5 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -21,6 +21,8 @@ import _ from "lodash"; import config from "../../config"; +import store from "../store"; +import { createAction } from "redux-actions"; const isDev = process.env.APPMODE === "development"; const logger = {}; @@ -77,4 +79,25 @@ if (leLogger) { extend("warn", "warning"); } +/** + * The function behaves similarly to javascript alert() + * it will show a modal error diaglog with styling until the user clicks OK. + */ +export const fireErrorMessage = (title, details) => { + setImmediate(() => { + const newError = createAction("NEW_ERROR", (paramTitle, paramDetails) => ({ + title: paramTitle, + details: paramDetails, + })); + store.dispatch(newError(title, details)); + }); +}; + +export const clearErrorMesssage = () => { + setImmediate(() => { + const clearError = createAction("CLEAR_ERROR"); + store.dispatch(clearError()); + }); +}; + export default logger; diff --git a/src/utils/submission.js b/src/utils/submission.js index ddef534..106c858 100644 --- a/src/utils/submission.js +++ b/src/utils/submission.js @@ -180,4 +180,83 @@ export function processMMSubmissions(submissions) { return finalSubmissions; } +export const isSubmissionEnded = (challenge) => { + const { status, phases } = challenge; + + return ( + status === "COMPLETED" || + (!_.some(phases, { name: "Submission", isOpen: true }) && + !_.some(phases, { name: "Checkpoint Submission", isOpen: true })) + ); +}; + +export const canSubmitFinalFixes = (challenge, handle) => { + const { winners, phases } = challenge; + const hasFirstPlacement = + !_.isEmpty(winners) && _.some(winners, { placement: 1, handle }); + + let canSubmit = false; + if (hasFirstPlacement && !_.isEmpty(phases)) { + canSubmit = _.some(phases, { phaseType: "Final Fix", isOpen: true }); + } + + return canSubmit; +}; + +export const isChallengeBelongToTopgearGroup = (challenge, communityList) => { + const { groups } = challenge; + + // check if challenge belong to any group + if (!_.isEmpty(groups)) { + return false; + } + + const topGearCommunity = _.find(communityList, { mainSubdomain: "topgear" }); + if (!topGearCommunity) { + return false; + } + + // check the group info match with group list + for (let i = 0; i < groups.length; i += 1) { + if (groups[i] && _.includes(topGearCommunity.groupIds, groups[i])) { + return true; + } + } + + return false; +}; + +export const getSubmissionDetail = (challenge) => { + const { phases } = challenge; + + const checkpoint = _.find(phases, { + name: "Checkpoint Submission", + }); + const submission = _.find(phases, { + name: "Submission", + }); + const finalFix = _.find(phases, { + name: "Final Fix", + }); + let subType; + + // Submission type logic + if (checkpoint && checkpoint.isOpen) { + subType = "Checkpoint Submission"; + } else if ( + checkpoint && + !checkpoint.isOpen && + submission && + submission.isOpen + ) { + subType = "Contest Submission"; + } else if (finalFix && finalFix.isOpen) { + subType = "Studio Final Fix Submission"; + } else { + subType = "Contest Submission"; + } + + return subType; +}; + export default undefined;