diff --git a/.circleci/config.yml b/.circleci/config.yml index dfb7f6a9..e5ee2959 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,7 +74,7 @@ builddeploy_steps: &builddeploy_steps source awsenvconf ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-deployvar source buildenvvar - ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} + ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} -p FARGATE #curl --request POST \ #--url https://circleci.com/api/v2/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pipeline \ #--header "Circle-Token: ${CIRCLE_TOKEN}" \ @@ -152,7 +152,7 @@ workflows: context: org-global filters: &filters-dev branches: - only: ["develop", "multiround", "release_0.20.9", "metadata-fix"] + only: ["develop", "PM-803_wm-regression-fixes", "PM-902_show-all-projects-on-challenge-page", "pm-1355_1"] # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/.github/workflows/code_reviewer.yml b/.github/workflows/code_reviewer.yml new file mode 100644 index 00000000..1d313051 --- /dev/null +++ b/.github/workflows/code_reviewer.yml @@ -0,0 +1,22 @@ +name: AI PR Reviewer + +on: + pull_request: + types: + - opened + - synchronize +permissions: + pull-requests: write +jobs: + tc-ai-pr-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: TC AI PR Reviewer + uses: topcoder-platform/tc-ai-pr-reviewer@master + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # The GITHUB_TOKEN is there by default so you just need to keep it like it is and not necessarily need to add it as secret as it will throw an error. [More Details](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#about-the-github_token-secret) + LAB45_API_KEY: ${{ secrets.LAB45_API_KEY }} + exclude: "**/*.json, **/*.md, **/*.jpg, **/*.png, **/*.jpeg, **/*.bmp, **/*.webp" # Optional: exclude patterns separated by commas diff --git a/config/constants/development.js b/config/constants/development.js index fd7938dc..e418af3c 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -19,12 +19,15 @@ module.exports = { CHALLENGE_TRACKS_URL: `${DEV_API_HOSTNAME}/v5/challenge-tracks`, CHALLENGE_PHASES_URL: `${DEV_API_HOSTNAME}/v5/challenge-phases`, CHALLENGE_TIMELINES_URL: `${DEV_API_HOSTNAME}/v5/challenge-timelines`, + COPILOTS_URL: `https://copilots.${DOMAIN}`, PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, GROUPS_API_URL: `${DEV_API_HOSTNAME}/v5/groups`, TERMS_API_URL: `${DEV_API_HOSTNAME}/v5/terms`, + MEMBERS_API_URL: `${DEV_API_HOSTNAME}/v5/members`, RESOURCES_API_URL: `${DEV_API_HOSTNAME}/v5/resources`, RESOURCE_ROLES_API_URL: `${DEV_API_HOSTNAME}/v5/resource-roles`, SUBMISSIONS_API_URL: `${DEV_API_HOSTNAME}/v5/submissions`, + REVIEW_TYPE_API_URL: `${DEV_API_HOSTNAME}/v5/reviewTypes`, SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, @@ -44,8 +47,10 @@ module.exports = { CREATE_FORUM_TYPE_IDS: ['927abff4-7af9-4145-8ba1-577c16e64e2e', 'dc876fa4-ef2d-4eee-b701-b555fcc6544c', 'ecd58c69-238f-43a4-a4bb-d172719b9f31', '78b37a69-92d5-4ad7-bf85-c79b65420c79', '929bc408-9cf2-4b3e-ba71-adfbf693046c'], FILE_PICKER_API_KEY: process.env.FILE_PICKER_API_KEY, FILE_PICKER_CONTAINER_NAME: 'tc-challenge-v5-dev', + FILE_PICKER_SUBMISSION_CONTAINER_NAME: process.env.FILE_PICKER_SUBMISSION_CONTAINER_NAME || 'submission-staging-dev', FILE_PICKER_REGION: 'us-east-1', FILE_PICKER_CNAME: 'fs.topcoder.com', + FILE_PICKER_LOCATION: 's3', // if idle for this many minutes, show user a prompt saying they'll be logged out IDLE_TIMEOUT_MINUTES: 10, // duration to show the prompt saying user will be logged out, before actually logging out the user @@ -57,5 +62,5 @@ module.exports = { SKILLS_V5_API_URL: `${API_V5}/standardized-skills/skills/autocomplete`, UPDATE_SKILLS_V5_API_URL: `${API_V5}/standardized-skills/challenge-skills`, SALESFORCE_BILLING_ACCOUNT_LINK: 'https://c.cs18.visual.force.com/apex/baredirect?id=', - TYPEFORM_URL: 'https://topcoder.typeform.com/to/YJ7AL4p8' + PROFILE_URL: 'https://profiles.topcoder-dev.com/' } diff --git a/config/constants/production.js b/config/constants/production.js index 56a94c63..882ee5be 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -18,12 +18,15 @@ module.exports = { CHALLENGE_TRACKS_URL: `${PROD_API_HOSTNAME}/v5/challenge-tracks`, CHALLENGE_PHASES_URL: `${PROD_API_HOSTNAME}/v5/challenge-phases`, CHALLENGE_TIMELINES_URL: `${PROD_API_HOSTNAME}/v5/challenge-timelines`, + COPILOTS_URL: `https://copilots.${DOMAIN}`, PROJECT_API_URL: `${PROD_API_HOSTNAME}/v5/projects`, GROUPS_API_URL: `${PROD_API_HOSTNAME}/v5/groups`, TERMS_API_URL: `${PROD_API_HOSTNAME}/v5/terms`, + MEMBERS_API_URL: `${PROD_API_HOSTNAME}/v5/members`, RESOURCES_API_URL: `${PROD_API_HOSTNAME}/v5/resources`, RESOURCE_ROLES_API_URL: `${PROD_API_HOSTNAME}/v5/resource-roles`, SUBMISSIONS_API_URL: `${PROD_API_HOSTNAME}/v5/submissions`, + REVIEW_TYPE_API_URL: `${PROD_API_HOSTNAME}/v5/reviewTypes`, SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, @@ -43,8 +46,10 @@ module.exports = { CREATE_FORUM_TYPE_IDS: ['927abff4-7af9-4145-8ba1-577c16e64e2e', 'dc876fa4-ef2d-4eee-b701-b555fcc6544c', 'ecd58c69-238f-43a4-a4bb-d172719b9f31', '78b37a69-92d5-4ad7-bf85-c79b65420c79', '929bc408-9cf2-4b3e-ba71-adfbf693046c'], FILE_PICKER_API_KEY: process.env.FILE_PICKER_API_KEY, FILE_PICKER_CONTAINER_NAME: 'tc-challenge-v5-prod', + FILE_PICKER_SUBMISSION_CONTAINER_NAME: process.env.FILE_PICKER_SUBMISSION_CONTAINER_NAME || 'submission-staging-prod', FILE_PICKER_REGION: 'us-east-1', FILE_PICKER_CNAME: 'fs.topcoder.com', + FILE_PICKER_LOCATION: 's3', IDLE_TIMEOUT_MINUTES: 10, IDLE_TIMEOUT_GRACE_MINUTES: 5, MULTI_ROUND_CHALLENGE_TEMPLATE_ID: 'd4201ca4-8437-4d63-9957-3f7708184b07', @@ -54,5 +59,5 @@ module.exports = { SKILLS_V5_API_URL: `${API_V5}/standardized-skills/skills/autocomplete`, UPDATE_SKILLS_V5_API_URL: `${API_V5}/standardized-skills/challenge-skills`, SALESFORCE_BILLING_ACCOUNT_LINK: 'https://topcoder.my.salesforce.com/apex/baredirect?id=', - TYPEFORM_URL: 'https://topcoder.typeform.com/to/YJ7AL4p8' + PROFILE_URL: 'https://profiles.topcoder.com/' } diff --git a/package-lock.json b/package-lock.json index 26029628..60545c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2931,6 +2931,11 @@ "prop-types": "^15.7.2" } }, + "@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==" + }, "@jridgewell/gen-mapping": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", @@ -3137,6 +3142,25 @@ "resolved": "https://registry.npmjs.org/@tanem/svg-injector/-/svg-injector-1.2.1.tgz", "integrity": "sha512-mA5Q5ulPoGQ+e08Vts1R6xw2QU0BKEnMH/KcqoYoS7Gk6imvMTpyFPeu1g+NOZObSIoAzA3/kRzY8m96cEBA2A==" }, + "@toast-ui/editor": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@toast-ui/editor/-/editor-2.5.4.tgz", + "integrity": "sha512-XsuYlPQxhec9dHQREFAigjE4enHSuGMF7D0YQ6wW7phmusvAu0FnJfZUPjJBoU/GKz7WP5U6fKU9/P+8j65D8A==", + "requires": { + "@types/codemirror": "0.0.71", + "codemirror": "^5.48.4" + }, + "dependencies": { + "@types/codemirror": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.71.tgz", + "integrity": "sha512-b2oEEnno1LIGKMR7uBEsr40al1UijF1HEpRn0+Yf1xOLl24iQgB7DBpZVMM7y54G5wCNoclDrRO65E6KHPNO2w==", + "requires": { + "@types/tern": "*" + } + } + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3157,9 +3181,9 @@ } }, "@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "requires": { "@types/connect": "*", "@types/node": "*" @@ -3174,9 +3198,9 @@ } }, "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "requires": { "@types/node": "*" } @@ -3200,12 +3224,12 @@ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==" }, "@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", "requires": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", + "@types/express-serve-static-core": "^5.0.0", "@types/qs": "*", "@types/serve-static": "*" } @@ -3220,19 +3244,20 @@ } }, "@types/express-serve-static-core": { - "version": "4.17.33", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz", - "integrity": "sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "requires": { "@types/node": "*", "@types/qs": "*", - "@types/range-parser": "*" + "@types/range-parser": "*", + "@types/send": "*" } }, "@types/express-unless": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-2.0.1.tgz", - "integrity": "sha512-PJLiNw03EjkWDkQbhNjIXXDLObC3eMQhFASDV+WakFbT8eL7YdjlbV6MXd3Av5Lejq499d6pFuV1jyK+EHyG3Q==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-2.0.3.tgz", + "integrity": "sha512-iJbM7nsyBgnxCrCe7VjWIi4nyyhlaKUl7jxeHDpK+KXk3sYrUZViMkgFv9qSZmxDleB8dfpQR9gK5MGNyM/M6w==", "requires": { "express-unless": "*" } @@ -3255,6 +3280,30 @@ "@types/unist": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + } + } + }, + "@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, "@types/katex": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.11.1.tgz", @@ -3274,9 +3323,9 @@ } }, "@types/mime": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", - "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==" + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "@types/minimatch": { "version": "3.0.3", @@ -3299,9 +3348,9 @@ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==" }, "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "@types/q": { "version": "1.5.2", @@ -3309,24 +3358,49 @@ "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==" }, "@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" }, "@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, - "@types/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "requires": { + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + } + } + }, + "@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "requires": { - "@types/mime": "*", + "@types/mime": "^1", "@types/node": "*" } }, + "@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "requires": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "@types/tapable": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.2.tgz", @@ -5425,13 +5499,29 @@ "unset-value": "^1.0.0" } }, - "call-bind": { + "call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + } + } + }, + "call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" } }, "call-me-maybe": { @@ -5819,6 +5909,11 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "codemirror": { + "version": "5.65.18", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.18.tgz", + "integrity": "sha512-Gaz4gHnkbHMGgahNt3CA5HBk5lLQBqmD/pBgeB4kQU6OedZmqMBjlRF0LSrp2tJ4wlLNPm2FfaUd1pDy0mdlpA==" + }, "codemirror-spell-checker": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz", @@ -6043,9 +6138,9 @@ } }, "config": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/config/-/config-3.3.9.tgz", - "integrity": "sha512-G17nfe+cY7kR0wVpc49NCYvNtelm/pPy8czHoFkAgtV1lkmcp7DHtWCdDu+C9Z7gb2WVqa9Tm3uF9aKaPbCfhg==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz", + "integrity": "sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==", "requires": { "json5": "^2.2.3" }, @@ -6119,18 +6214,18 @@ "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" }, "cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "requires": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, "dependencies": { "cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" } } }, @@ -6291,9 +6386,9 @@ } }, "crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "css-animation": { "version": "1.6.1", @@ -6821,6 +6916,11 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" }, + "deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" + }, "default-gateway": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz", @@ -7024,6 +7124,15 @@ } } }, + "dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "requires": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -7224,6 +7333,16 @@ "nan": "^2.14.0" } }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "duplexer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -7459,6 +7578,35 @@ "string.prototype.trimright": "^2.1.1" } }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, "es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -8776,12 +8924,13 @@ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, @@ -8791,9 +8940,60 @@ "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==" }, "formidable": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", - "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "requires": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "dependencies": { + "qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "requires": { + "side-channel": "^1.1.0" + } + } + } + }, + "formik": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } }, "forwarded": { "version": "0.1.2", @@ -8976,22 +9176,43 @@ "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==" }, "get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "dependencies": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" } } }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", @@ -9128,6 +9349,11 @@ "minimatch": "~3.0.2" } }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, "graceful-fs": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", @@ -9276,6 +9502,21 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + }, + "dependencies": { + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + } + } + }, "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -9354,6 +9595,21 @@ "minimalistic-assert": "^1.0.1" } }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + }, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + } + } + }, "hast-to-hyperscript": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz", @@ -9523,6 +9779,11 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==" + }, "hide-powered-by": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz", @@ -10121,12 +10382,12 @@ } }, "idtoken-verifier": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-2.2.3.tgz", - "integrity": "sha512-hhpzB+MRgEvbwqzRLFdVbG55lKdXQVfeYEjAA2qu0UC72MSLeR0nX7P7rY5Dycz1aISHPOwq80hIPFoJ/+SItA==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/idtoken-verifier/-/idtoken-verifier-2.2.4.tgz", + "integrity": "sha512-5t7O8cNHpJBB8FnwLD0qFZqy/+qGICObQKUl0njD6vXKHhpZPLEe8LU7qv/GBWB3Qv5e/wAIFHYVi4SoQwdOxQ==", "requires": { "base64-js": "^1.5.1", - "crypto-js": "^4.1.1", + "crypto-js": "^4.2.0", "es6-promise": "^4.2.8", "jsbn": "^1.1.0", "unfetch": "^4.2.0", @@ -11667,9 +11928,9 @@ } }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" } } }, @@ -12055,22 +12316,26 @@ } }, "lru-memoizer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.2.0.tgz", - "integrity": "sha512-QfOZ6jNkxCcM/BkIPnFsqDhtrazLRsghi9mBwFAzol5GCvj4EkFT899Za3+QwikCg5sRX8JstioBDwOxEyzaNw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", "requires": { "lodash.clonedeep": "^4.5.0", - "lru-cache": "~4.0.0" + "lru-cache": "6.0.0" }, "dependencies": { "lru-cache": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "requires": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" + "yallist": "^4.0.0" } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } }, @@ -12136,6 +12401,11 @@ "repeat-string": "^1.0.0" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, "math-random": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", @@ -13007,9 +13277,9 @@ "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==" }, "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" }, "nanomatch": { "version": "1.2.13", @@ -17495,6 +17765,11 @@ "react-is": "^16.8.1" } }, + "property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "property-information": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", @@ -20073,27 +20348,82 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==" }, "shortid": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", - "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", + "version": "2.2.17", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.17.tgz", + "integrity": "sha512-GpbM3gLF1UUXZvQw6MCyulHkWbRseNO4cyBEZresZRorwl1+SLu1ZdqgVtuwqz8mB6RpwPkm541mYSqrKyJSaA==", "requires": { - "nanoid": "^2.1.0" + "nanoid": "^3.3.8" } }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "dependencies": { + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + } + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "dependencies": { + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + } + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "dependencies": { + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + } + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "dependencies": { "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" } } }, @@ -21108,29 +21438,29 @@ } }, "superagent": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-5.3.1.tgz", - "integrity": "sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-7.1.6.tgz", + "integrity": "sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==", "requires": { "component-emitter": "^1.3.0", - "cookiejar": "^2.1.2", - "debug": "^4.1.1", - "fast-safe-stringify": "^2.0.7", - "form-data": "^3.0.0", - "formidable": "^1.2.2", + "cookiejar": "^2.1.3", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.0.1", "methods": "^1.1.2", - "mime": "^2.4.6", - "qs": "^6.9.4", + "mime": "2.6.0", + "qs": "^6.10.3", "readable-stream": "^3.6.0", - "semver": "^7.3.2" + "semver": "^7.3.7" }, "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "requires": { - "yallist": "^4.0.0" + "ms": "^2.1.3" } }, "mime": { @@ -21138,26 +21468,23 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" } } }, @@ -21294,15 +21621,16 @@ }, "dependencies": { "auth0-js": { - "version": "9.20.1", - "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.20.1.tgz", - "integrity": "sha512-m7k3O0Qs3Emr7cC2OPbbOp1duzgMzuTeESHgWK+FimGV6FjBX53dYtNIgQ49J7mkACeKje/Jlto9/6CO9YQhcQ==", + "version": "9.28.0", + "resolved": "https://registry.npmjs.org/auth0-js/-/auth0-js-9.28.0.tgz", + "integrity": "sha512-2xIfQIGM0vX3IdPR91ztLO2+Ar2I5+3iFKcjuZO+LV9vRh4Wje+Ka1hnHjMU9dH892Lm3ZxBAHxRo68YToUhfg==", "requires": { "base64-js": "^1.5.1", - "idtoken-verifier": "^2.2.2", + "idtoken-verifier": "^2.2.4", "js-cookie": "^2.2.0", + "minimist": "^1.2.5", "qs": "^6.10.1", - "superagent": "^5.3.1", + "superagent": "^7.1.5", "url-join": "^4.0.1", "winchan": "^0.2.2" } @@ -21343,11 +21671,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" } }, "winchan": { @@ -21553,6 +21881,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "tiny-invariant": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", @@ -21667,9 +22000,8 @@ } }, "topcoder-react-lib": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/topcoder-react-lib/-/topcoder-react-lib-1.2.10.tgz", - "integrity": "sha512-Z8XICIvYbrciM+8vOJCRGiqEX4EzJ50pNApJnJyGfutjplmB1ns0m7/DezJJuB2mrb3A5hNki9KGV2EM+W7lOQ==", + "version": "github:topcoder-platform/topcoder-react-lib#f728ef13f40ccbeac00ed0d1507997835e99058c", + "from": "github:topcoder-platform/topcoder-react-lib#1.2.18", "requires": { "@topcoder-platform/tc-auth-lib": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", "auth0-js": "^6.8.4", @@ -21799,6 +22131,11 @@ } } }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "tough-cookie": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", @@ -21914,6 +22251,11 @@ "prelude-ls": "~1.1.2" } }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -24272,6 +24614,17 @@ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" }, + "yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "requires": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/package.json b/package.json index dbd288db..8eab630d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.14", "@fortawesome/free-solid-svg-icons": "^5.7.1", "@fortawesome/react-fontawesome": "^0.1.4", + "@hookform/resolvers": "^3.10.0", "@nateradebaugh/react-datetime": "^4.4.11", "@popperjs/core": "^2.5.4", "@svgr/webpack": "2.4.1", @@ -114,7 +115,7 @@ "terser": "^3.16.1", "terser-webpack-plugin": "1.1.0", "topcoder-healthcheck-dropin": "^1.0.3", - "topcoder-react-lib": "^1.2.10", + "topcoder-react-lib": "github:topcoder-platform/topcoder-react-lib#1.2.18", "url-loader": "1.1.1", "webpack": "^4.43.0", "webpack-dev-server": "^3.11.0", diff --git a/public/static/comment.jpg b/public/static/comment.jpg new file mode 100644 index 00000000..52a7ce58 Binary files /dev/null and b/public/static/comment.jpg differ diff --git a/public/static/logo.jpg b/public/static/logo.jpg new file mode 100644 index 00000000..44cf1a5f Binary files /dev/null and b/public/static/logo.jpg differ diff --git a/src/actions/challengeSubmissions.js b/src/actions/challengeSubmissions.js index b060c19f..c2122209 100644 --- a/src/actions/challengeSubmissions.js +++ b/src/actions/challengeSubmissions.js @@ -2,12 +2,12 @@ import { fetchSubmissions } from '../services/challenges' import { LOAD_CHALLENGE_SUBMISSIONS } from '../config/constants' -export function loadSubmissions (challengeId) { - return dispatch => { +export function loadSubmissions (challengeId, page) { + return (dispatch) => { if (challengeId) { dispatch({ type: LOAD_CHALLENGE_SUBMISSIONS, - payload: fetchSubmissions(challengeId) + payload: fetchSubmissions(challengeId, page) }) } } diff --git a/src/actions/challenges.js b/src/actions/challenges.js index 7349d179..60eb631f 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -57,6 +57,7 @@ import { } from '../config/constants' import { loadProject } from './projects' import { removeChallengeFromPhaseProduct, saveChallengeAsPhaseProduct } from '../services/projects' +import { checkAdmin, checkManager } from '../util/tc' /** * Member challenges related redux actions @@ -158,7 +159,11 @@ export function loadChallengesByPage ( filters['projectId'] = projectId } else if (_.isObject(projectId) && projectId.value > 0) { filters['projectId'] = projectId.value - } else if (userId) { + } else if ( + !checkAdmin(getState().auth.token) && + !checkManager(getState().auth.token) && + userId + ) { // Note that we only add the memberId field if *no* project ID is given, // so that the list of *all challenges shows only those that the member is on filters['memberId'] = userId diff --git a/src/actions/projects.js b/src/actions/projects.js index 45d18a58..dfc6bf9b 100644 --- a/src/actions/projects.js +++ b/src/actions/projects.js @@ -1,4 +1,12 @@ +import _ from 'lodash' + import { + PROJECT_TYPE_TAAS, + PROJECTS_PAGE_SIZE, + LOAD_PROJECTS_PENDING, + LOAD_PROJECTS_SUCCESS, + UNLOAD_PROJECTS_SUCCESS, + LOAD_PROJECTS_FAILURE, LOAD_PROJECT_BILLING_ACCOUNT, LOAD_CHALLENGE_MEMBERS_SUCCESS, LOAD_PROJECT_DETAILS, @@ -9,7 +17,11 @@ import { LOAD_PROJECT_BILLING_ACCOUNTS, UPDATE_PROJECT_PENDING, UPDATE_PROJECT_SUCCESS, - UPDATE_PROJECT_FAILURE + UPDATE_PROJECT_FAILURE, + ADD_PROJECT_ATTACHMENT_SUCCESS, + UPDATE_PROJECT_ATTACHMENT_SUCCESS, + REMOVE_PROJECT_ATTACHMENT_SUCCESS, + LOAD_PROJECT_INVITES } from '../config/constants' import { fetchProjectById, @@ -18,8 +30,97 @@ import { getProjectTypes, createProjectApi, fetchBillingAccounts, - updateProjectApi + fetchMemberProjects, + updateProjectApi, + getProjectInvites } from '../services/projects' +import { checkAdmin, checkManager } from '../util/tc' + +function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { + return (dispatch, getState) => { + dispatch({ + type: LOAD_PROJECTS_PENDING + }) + + const filters = { + sort: 'lastActivityAt desc', + perPage: PROJECTS_PAGE_SIZE, + ...paramFilters + } + + if (!_.isEmpty(projectNameOrIdFilter)) { + if (!isNaN(projectNameOrIdFilter)) { // if it is number + filters['id'] = parseInt(projectNameOrIdFilter, 10) + } else { // text search + filters['keyword'] = decodeURIComponent(projectNameOrIdFilter) + } + } + + if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) { + filters['memberOnly'] = true + } + + // eslint-disable-next-line no-debugger + const state = getState().projects + fetchMemberProjects(filters).then(({ projects, pagination }) => dispatch({ + filters, + type: LOAD_PROJECTS_SUCCESS, + projects: _.uniqBy((filters.page ? state.projects || [] : []).concat(projects), 'id'), + total: pagination.xTotal, + page: pagination.xPage + })).catch(() => dispatch({ + type: LOAD_PROJECTS_FAILURE + })) + } +} + +export function loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { + return async (dispatch, getState) => { + const _filters = _.assign({}, paramFilters) + if (_.isEmpty(_filters) || !_filters.type) { + let projectTypes = getState().projects.projectTypes + + if (!projectTypes.length) { + dispatch({ + type: LOAD_PROJECTS_PENDING + }) + await loadProjectTypes()(dispatch) + projectTypes = getState().projects.projectTypes + } + + _.assign(_filters, { + type: projectTypes.filter(d => d.key !== PROJECT_TYPE_TAAS).map(d => d.key) + }) + } + + return _loadProjects(projectNameOrIdFilter, _filters)(dispatch, getState) + } +} + +/** + * Load more projects for the authenticated user + */ +export function loadMoreProjects () { + return (dispatch, getState) => { + const { projectFilters, projectsPage } = getState().projects + + loadProjects('', _.assign({}, projectFilters, { + perPage: PROJECTS_PAGE_SIZE, + page: projectsPage + 1 + }))(dispatch, getState) + } +} + +/** + * Unloads projects of the authenticated user + */ +export function unloadProjects () { + return (dispatch) => { + dispatch({ + type: UNLOAD_PROJECTS_SUCCESS + }) + } +} /** * Loads project details @@ -72,6 +173,18 @@ export function loadProjectTypes () { } } +/** + * Loads project invites + */ +export function loadProjectInvites (projectId) { + return (dispatch) => { + return dispatch({ + type: LOAD_PROJECT_INVITES, + payload: getProjectInvites(projectId) + }) + } +} + /** * Creates a project */ @@ -84,6 +197,45 @@ export function createProject (project) { } } +/** + * Add attachment to project + * @param {Object} newAttachment new attachment data + */ +export function addAttachment (newAttachment) { + return (dispatch) => { + return dispatch({ + type: ADD_PROJECT_ATTACHMENT_SUCCESS, + payload: newAttachment + }) + } +} + +/** + * Update project attachment + * @param {Object} newAttachment new attachment data + */ +export function updateAttachment (newAttachment) { + return (dispatch) => { + return dispatch({ + type: UPDATE_PROJECT_ATTACHMENT_SUCCESS, + payload: newAttachment + }) + } +} + +/** + * Remove project attachment + * @param {number} attachmentId attachment id + */ +export function removeAttachment (attachmentId) { + return (dispatch) => { + return dispatch({ + type: REMOVE_PROJECT_ATTACHMENT_SUCCESS, + payload: attachmentId + }) + } +} + /** * Only loads project details * @param {String} projectId Id of the project diff --git a/src/actions/sidebar.js b/src/actions/sidebar.js index 4a843747..0c02d8f4 100644 --- a/src/actions/sidebar.js +++ b/src/actions/sidebar.js @@ -8,8 +8,10 @@ import { LOAD_PROJECTS_PENDING, LOAD_PROJECTS_SUCCESS, RESET_SIDEBAR_ACTIVE_PARAMS, - UNLOAD_PROJECTS_SUCCESS + UNLOAD_PROJECTS_SUCCESS, + PROJECTS_PAGE_SIZE } from '../config/constants' +import { checkAdmin, checkManager } from '../util/tc' import _ from 'lodash' /** @@ -28,8 +30,8 @@ export function setActiveProject (projectId) { /** * Loads projects of the authenticated user */ -export function loadProjects (filterProjectName = '', myProjects = true, paramFilters = {}) { - return (dispatch) => { +export function loadProjects (filterProjectName = '', paramFilters = {}) { + return (dispatch, getState) => { dispatch({ type: LOAD_PROJECTS_PENDING }) @@ -37,6 +39,7 @@ export function loadProjects (filterProjectName = '', myProjects = true, paramFi const filters = { status: 'active', sort: 'lastActivityAt desc', + perPage: PROJECTS_PAGE_SIZE, ...paramFilters } if (!_.isEmpty(filterProjectName)) { @@ -47,21 +50,35 @@ export function loadProjects (filterProjectName = '', myProjects = true, paramFi } } - // filters['perPage'] = 20 - // filters['page'] = 1 - if (myProjects) { + if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) { filters['memberOnly'] = true } - fetchMemberProjects(filters).then(projects => dispatch({ + // eslint-disable-next-line no-debugger + const state = getState().sidebar + fetchMemberProjects(filters).then(({ projects, pagination }) => dispatch({ type: LOAD_PROJECTS_SUCCESS, - projects + projects: _.uniqBy((state.projects || []).concat(projects), 'id'), + total: pagination.xTotal, + page: pagination.xPage })).catch(() => dispatch({ type: LOAD_PROJECTS_FAILURE })) } } +// Load next page of projects +export function loadNextProjects () { + return (dispatch, getState) => { + const { projectFilters, projectsPage } = getState().sidebar + + loadProjects('', _.assign({}, projectFilters, { + perPage: PROJECTS_PAGE_SIZE, + page: projectsPage + 1 + }))(dispatch, getState) + } +} + /** * Unloads projects of the authenticated user */ diff --git a/src/actions/users.js b/src/actions/users.js index 92e54cc1..31a65ab2 100644 --- a/src/actions/users.js +++ b/src/actions/users.js @@ -10,33 +10,55 @@ import { SEARCH_USER_PROJECTS_SUCCESS, SEARCH_USER_PROJECTS_FAILURE } from '../config/constants' +import _ from 'lodash' /** * Loads projects of the authenticated user */ -export function loadAllUserProjects (isAdmin = true) { - return (dispatch) => { +export function loadAllUserProjects (params, isAdmin = true, isManager = true) { + return (dispatch, getState) => { dispatch({ type: LOAD_ALL_USER_PROJECTS_PENDING }) + const state = getState().users + const filters = { status: 'active', - sort: 'lastActivityAt desc' + sort: 'lastActivityAt desc', + perPage: 20, + ...params } - if (!isAdmin) { + + if (!isAdmin && !isManager) { filters['memberOnly'] = true } - fetchMemberProjects(filters).then(projects => dispatch({ + fetchMemberProjects(filters).then(({ projects, pagination }) => dispatch({ type: LOAD_ALL_USER_PROJECTS_SUCCESS, - projects + projects: _.uniqBy((filters.page ? state.allUserProjects || [] : []).concat(projects), 'id'), + total: pagination.xTotal, + page: pagination.xPage })).catch(() => dispatch({ type: LOAD_ALL_USER_PROJECTS_FAILURE })) } } +export function loadNextProjects (isAdmin = true, isManager = true) { + return (dispatch, getState) => { + const { page, total, allUserProjects } = getState().users + if (allUserProjects.length >= total) { + return + } + + loadAllUserProjects(_.assign({}, { + perPage: 20, + page: page + 1 + }), isAdmin, isManager)(dispatch, getState) + } +} + /** * Filter projects of the authenticated user * @@ -66,7 +88,7 @@ export function searchUserProjects (isAdmin = true, keyword) { filters['memberOnly'] = true } - fetchMemberProjects(filters).then(projects => dispatch({ + fetchMemberProjects(filters).then(({ projects }) => dispatch({ type: SEARCH_USER_PROJECTS_SUCCESS, projects })).catch(() => dispatch({ diff --git a/src/assets/images/IconDownloadArtifacts.svg b/src/assets/images/IconDownloadArtifacts.svg new file mode 100644 index 00000000..ca1c58fe --- /dev/null +++ b/src/assets/images/IconDownloadArtifacts.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/IconReviewRatingList.svg b/src/assets/images/IconReviewRatingList.svg new file mode 100644 index 00000000..588f4972 --- /dev/null +++ b/src/assets/images/IconReviewRatingList.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/files/aac.svg b/src/assets/images/files/aac.svg new file mode 100644 index 00000000..38d95bbf --- /dev/null +++ b/src/assets/images/files/aac.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/ai.svg b/src/assets/images/files/ai.svg new file mode 100644 index 00000000..33d19427 --- /dev/null +++ b/src/assets/images/files/ai.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/ase.svg b/src/assets/images/files/ase.svg new file mode 100644 index 00000000..ad573707 --- /dev/null +++ b/src/assets/images/files/ase.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/asp.svg b/src/assets/images/files/asp.svg new file mode 100644 index 00000000..0a254722 --- /dev/null +++ b/src/assets/images/files/asp.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/aspx.svg b/src/assets/images/files/aspx.svg new file mode 100644 index 00000000..de4dd224 --- /dev/null +++ b/src/assets/images/files/aspx.svg @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/avi.svg b/src/assets/images/files/avi.svg new file mode 100644 index 00000000..9da8a8c3 --- /dev/null +++ b/src/assets/images/files/avi.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/bmp.svg b/src/assets/images/files/bmp.svg new file mode 100644 index 00000000..d966f646 --- /dev/null +++ b/src/assets/images/files/bmp.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/c++.svg b/src/assets/images/files/c++.svg new file mode 100644 index 00000000..bbe352c4 --- /dev/null +++ b/src/assets/images/files/c++.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/cad.svg b/src/assets/images/files/cad.svg new file mode 100644 index 00000000..16ee1953 --- /dev/null +++ b/src/assets/images/files/cad.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/cfm.svg b/src/assets/images/files/cfm.svg new file mode 100644 index 00000000..79870942 --- /dev/null +++ b/src/assets/images/files/cfm.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/cgi.svg b/src/assets/images/files/cgi.svg new file mode 100644 index 00000000..e8811aa6 --- /dev/null +++ b/src/assets/images/files/cgi.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/csh.svg b/src/assets/images/files/csh.svg new file mode 100644 index 00000000..eb5cca5d --- /dev/null +++ b/src/assets/images/files/csh.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/css.svg b/src/assets/images/files/css.svg new file mode 100644 index 00000000..59692551 --- /dev/null +++ b/src/assets/images/files/css.svg @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/csv.svg b/src/assets/images/files/csv.svg new file mode 100644 index 00000000..e1dd6611 --- /dev/null +++ b/src/assets/images/files/csv.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/default.svg b/src/assets/images/files/default.svg new file mode 100644 index 00000000..2676714d --- /dev/null +++ b/src/assets/images/files/default.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/images/files/dmg.svg b/src/assets/images/files/dmg.svg new file mode 100644 index 00000000..1e55afb8 --- /dev/null +++ b/src/assets/images/files/dmg.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/doc.svg b/src/assets/images/files/doc.svg new file mode 100644 index 00000000..dee20735 --- /dev/null +++ b/src/assets/images/files/doc.svg @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/docx.svg b/src/assets/images/files/docx.svg new file mode 100644 index 00000000..298c8c0e --- /dev/null +++ b/src/assets/images/files/docx.svg @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/eps.svg b/src/assets/images/files/eps.svg new file mode 100644 index 00000000..35879b7a --- /dev/null +++ b/src/assets/images/files/eps.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/epub.svg b/src/assets/images/files/epub.svg new file mode 100644 index 00000000..7e18a411 --- /dev/null +++ b/src/assets/images/files/epub.svg @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/exe.svg b/src/assets/images/files/exe.svg new file mode 100644 index 00000000..6d9238cc --- /dev/null +++ b/src/assets/images/files/exe.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/flash.svg b/src/assets/images/files/flash.svg new file mode 100644 index 00000000..7ead3253 --- /dev/null +++ b/src/assets/images/files/flash.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/flv.svg b/src/assets/images/files/flv.svg new file mode 100644 index 00000000..375ae6d4 --- /dev/null +++ b/src/assets/images/files/flv.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/font.svg b/src/assets/images/files/font.svg new file mode 100644 index 00000000..841cf5a9 --- /dev/null +++ b/src/assets/images/files/font.svg @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/gif.svg b/src/assets/images/files/gif.svg new file mode 100644 index 00000000..eefd4a67 --- /dev/null +++ b/src/assets/images/files/gif.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/gpx.svg b/src/assets/images/files/gpx.svg new file mode 100644 index 00000000..1052c35c --- /dev/null +++ b/src/assets/images/files/gpx.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/gzip.svg b/src/assets/images/files/gzip.svg new file mode 100644 index 00000000..3547af13 --- /dev/null +++ b/src/assets/images/files/gzip.svg @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/html.svg b/src/assets/images/files/html.svg new file mode 100644 index 00000000..5400dc37 --- /dev/null +++ b/src/assets/images/files/html.svg @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/ics.svg b/src/assets/images/files/ics.svg new file mode 100644 index 00000000..c5bc30f8 --- /dev/null +++ b/src/assets/images/files/ics.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/iso.svg b/src/assets/images/files/iso.svg new file mode 100644 index 00000000..a3154bd5 --- /dev/null +++ b/src/assets/images/files/iso.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/jar.svg b/src/assets/images/files/jar.svg new file mode 100644 index 00000000..73320066 --- /dev/null +++ b/src/assets/images/files/jar.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/java.svg b/src/assets/images/files/java.svg new file mode 100644 index 00000000..b4e93e49 --- /dev/null +++ b/src/assets/images/files/java.svg @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/jpg.svg b/src/assets/images/files/jpg.svg new file mode 100644 index 00000000..d2c71c95 --- /dev/null +++ b/src/assets/images/files/jpg.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/js.svg b/src/assets/images/files/js.svg new file mode 100644 index 00000000..d3830b5c --- /dev/null +++ b/src/assets/images/files/js.svg @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/jsp.svg b/src/assets/images/files/jsp.svg new file mode 100644 index 00000000..7c73e070 --- /dev/null +++ b/src/assets/images/files/jsp.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/link-12.svg b/src/assets/images/files/link-12.svg new file mode 100644 index 00000000..c7a68910 --- /dev/null +++ b/src/assets/images/files/link-12.svg @@ -0,0 +1,11 @@ + + + + DEA252EB-7149-42DE-B34C-229261299CDE + Created with sketchtool. + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/log.svg b/src/assets/images/files/log.svg new file mode 100644 index 00000000..ddef4c49 --- /dev/null +++ b/src/assets/images/files/log.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/max.svg b/src/assets/images/files/max.svg new file mode 100644 index 00000000..cda62f32 --- /dev/null +++ b/src/assets/images/files/max.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/md.svg b/src/assets/images/files/md.svg new file mode 100644 index 00000000..d94d0e8e --- /dev/null +++ b/src/assets/images/files/md.svg @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/mkv.svg b/src/assets/images/files/mkv.svg new file mode 100644 index 00000000..78ac9de0 --- /dev/null +++ b/src/assets/images/files/mkv.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/mov.svg b/src/assets/images/files/mov.svg new file mode 100644 index 00000000..63fdcceb --- /dev/null +++ b/src/assets/images/files/mov.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/mp3.svg b/src/assets/images/files/mp3.svg new file mode 100644 index 00000000..cd0004d7 --- /dev/null +++ b/src/assets/images/files/mp3.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/mp4.svg b/src/assets/images/files/mp4.svg new file mode 100644 index 00000000..07efa4fb --- /dev/null +++ b/src/assets/images/files/mp4.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/mpg.svg b/src/assets/images/files/mpg.svg new file mode 100644 index 00000000..8f0063bf --- /dev/null +++ b/src/assets/images/files/mpg.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/obj.svg b/src/assets/images/files/obj.svg new file mode 100644 index 00000000..b10608e5 --- /dev/null +++ b/src/assets/images/files/obj.svg @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/otf.svg b/src/assets/images/files/otf.svg new file mode 100644 index 00000000..ec9e8a84 --- /dev/null +++ b/src/assets/images/files/otf.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/pdf.svg b/src/assets/images/files/pdf.svg new file mode 100644 index 00000000..e51db6af --- /dev/null +++ b/src/assets/images/files/pdf.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/php.svg b/src/assets/images/files/php.svg new file mode 100644 index 00000000..4fa31bf5 --- /dev/null +++ b/src/assets/images/files/php.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/png.svg b/src/assets/images/files/png.svg new file mode 100644 index 00000000..dc79b7b7 --- /dev/null +++ b/src/assets/images/files/png.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/pptx.svg b/src/assets/images/files/pptx.svg new file mode 100644 index 00000000..d6b420ee --- /dev/null +++ b/src/assets/images/files/pptx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/psd.svg b/src/assets/images/files/psd.svg new file mode 100644 index 00000000..c9505839 --- /dev/null +++ b/src/assets/images/files/psd.svg @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/py.svg b/src/assets/images/files/py.svg new file mode 100644 index 00000000..7ad94154 --- /dev/null +++ b/src/assets/images/files/py.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/rar.svg b/src/assets/images/files/rar.svg new file mode 100644 index 00000000..c11a7ea1 --- /dev/null +++ b/src/assets/images/files/rar.svg @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/raw.svg b/src/assets/images/files/raw.svg new file mode 100644 index 00000000..f636dace --- /dev/null +++ b/src/assets/images/files/raw.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/rb.svg b/src/assets/images/files/rb.svg new file mode 100644 index 00000000..4bec9d8d --- /dev/null +++ b/src/assets/images/files/rb.svg @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/rss.svg b/src/assets/images/files/rss.svg new file mode 100644 index 00000000..ee5e4420 --- /dev/null +++ b/src/assets/images/files/rss.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/rtf.svg b/src/assets/images/files/rtf.svg new file mode 100644 index 00000000..d6833570 --- /dev/null +++ b/src/assets/images/files/rtf.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/sketch.svg b/src/assets/images/files/sketch.svg new file mode 100644 index 00000000..ebe192e7 --- /dev/null +++ b/src/assets/images/files/sketch.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/sql.svg b/src/assets/images/files/sql.svg new file mode 100644 index 00000000..75b8f3bc --- /dev/null +++ b/src/assets/images/files/sql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/files/srt.svg b/src/assets/images/files/srt.svg new file mode 100644 index 00000000..ab7e8658 --- /dev/null +++ b/src/assets/images/files/srt.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/svg.svg b/src/assets/images/files/svg.svg new file mode 100644 index 00000000..9923ee36 --- /dev/null +++ b/src/assets/images/files/svg.svg @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/tif.svg b/src/assets/images/files/tif.svg new file mode 100644 index 00000000..ca24a164 --- /dev/null +++ b/src/assets/images/files/tif.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/tiff.svg b/src/assets/images/files/tiff.svg new file mode 100644 index 00000000..928ce965 --- /dev/null +++ b/src/assets/images/files/tiff.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/ttf.svg b/src/assets/images/files/ttf.svg new file mode 100644 index 00000000..33800659 --- /dev/null +++ b/src/assets/images/files/ttf.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/txt.svg b/src/assets/images/files/txt.svg new file mode 100644 index 00000000..992f5d5e --- /dev/null +++ b/src/assets/images/files/txt.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/wav.svg b/src/assets/images/files/wav.svg new file mode 100644 index 00000000..989a380a --- /dev/null +++ b/src/assets/images/files/wav.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/xlsx.svg b/src/assets/images/files/xlsx.svg new file mode 100644 index 00000000..1555d7b3 --- /dev/null +++ b/src/assets/images/files/xlsx.svg @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/xml.svg b/src/assets/images/files/xml.svg new file mode 100644 index 00000000..f57f8428 --- /dev/null +++ b/src/assets/images/files/xml.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/files/zip.svg b/src/assets/images/files/zip.svg new file mode 100644 index 00000000..ed8c4a95 --- /dev/null +++ b/src/assets/images/files/zip.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/components/AssetsLibrary/DownloadFile/index.js b/src/components/AssetsLibrary/DownloadFile/index.js new file mode 100644 index 00000000..a76a95ba --- /dev/null +++ b/src/components/AssetsLibrary/DownloadFile/index.js @@ -0,0 +1,63 @@ +/* Component to render button to download project attachment file */ + +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' +import styles from './styles.module.scss' +import Loader from '../../Loader' +import { toastr } from 'react-redux-toastr' +import cn from 'classnames' +import { getProjectAttachment } from '../../../services/projects' +import ReactSVG from 'react-svg' +const Download = './IconSquareDownload.svg' +const assets = require.context('../../../assets/images', false, /svg/) + +const DownloadFile = ({ classsName, file, projectId }) => { + const [isLoading, setIsLoading] = useState(false) + + return ( + + ) +} + +DownloadFile.defaultProps = { + file: {} +} + +DownloadFile.propTypes = { + classsName: PropTypes.string, + projectId: PropTypes.string, + file: PropTypes.shape() +} + +export default DownloadFile diff --git a/src/components/AssetsLibrary/DownloadFile/styles.module.scss b/src/components/AssetsLibrary/DownloadFile/styles.module.scss new file mode 100644 index 00000000..fccd9fd5 --- /dev/null +++ b/src/components/AssetsLibrary/DownloadFile/styles.module.scss @@ -0,0 +1,35 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + padding: 0; + margin: 0; + border: none; + outline: none !important; + background-color: transparent; + color: $tc-gray-90 !important; + &:hover .downloadIcon { + opacity: 1; + } +} + +.loader { + width: auto; + height: auto; + margin-left: 5px; + + svg { + width: 20px; + height: 20px; + } +} + +.downloadIcon { + opacity: 0; + margin-left: 5px; + transition: 0.15s ease; + svg { + width: 12px; + height: 12px; + } +} \ No newline at end of file diff --git a/src/components/AssetsLibrary/ModalAddLink/index.js b/src/components/AssetsLibrary/ModalAddLink/index.js new file mode 100644 index 00000000..420f287a --- /dev/null +++ b/src/components/AssetsLibrary/ModalAddLink/index.js @@ -0,0 +1,178 @@ +/* Component to render add link modal */ + +import React, { useMemo, useState, useEffect, useCallback } from 'react' +import { useForm } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import { connect } from 'react-redux' +import _ from 'lodash' +import PropTypes from 'prop-types' +import styles from './styles.module.scss' +import cn from 'classnames' +import { toastr } from 'react-redux-toastr' +import Modal from '../../Modal' +import PrimaryButton from '../../Buttons/PrimaryButton' +import OutlineButton from '../../Buttons/OutlineButton' +import FieldLabelDynamic from '../../FieldLabelDynamic' +import FieldInput from '../../FieldInput' +import { assetsLibraryAddLinkSchema } from '../../../util/validation' +import { addAttachment, updateAttachment } from '../../../actions/projects' +import { + addProjectAttachmentApi, + updateProjectAttachmentApi +} from '../../../services/projects' +import { ATTACHMENT_TYPE_LINK } from '../../../config/constants' + +const ModalAddLink = ({ + classsName, + theme, + onCancel, + link, + addAttachment, + updateAttachment, + projectId +}) => { + const [isProcessing, setIsProcessing] = useState(false) + const buttonText = useMemo(() => (link ? 'Edit Link' : 'Add Link'), [link]) + + const { + register, + handleSubmit, + reset, + formState: { errors, isValid, isDirty } + } = useForm({ + defaultValues: { + title: '', + path: '' + }, + resolver: yupResolver(assetsLibraryAddLinkSchema), + mode: 'all' + }) + + useEffect(() => { + if (link) { + reset({ + title: link.title, + path: link.path + }) + } + }, [link]) + + const onSubmit = useCallback( + data => { + if (!link) { + setIsProcessing(true) + addProjectAttachmentApi(projectId, { + ...data, + tags: [], + type: ATTACHMENT_TYPE_LINK + }) + .then(result => { + toastr.success('Success', 'Added link to the project successfully.') + setIsProcessing(false) + addAttachment(result) + onCancel() + }) + .catch(e => { + setIsProcessing(false) + const errorMessage = _.get( + e, + 'response.data.message', + 'Failed to add link.' + ) + toastr.error('Error', errorMessage) + }) + } else { + setIsProcessing(true) + updateProjectAttachmentApi(projectId, link.id, data) + .then(result => { + toastr.success('Success', 'Updated link successfully.') + setIsProcessing(false) + updateAttachment(result) + onCancel() + }) + .catch(e => { + setIsProcessing(false) + const errorMessage = _.get( + e, + 'response.data.message', + 'Failed to update link.' + ) + toastr.error('Error', errorMessage) + }) + } + }, + [link, projectId] + ) + + return ( + +
+
{link ? 'EDIT LINK' : 'ADD A LINK'}
+ +
+ + + + + + +
+
+ +
+
+ +
+
+
+
+
+ ) +} + +ModalAddLink.defaultProps = { + isProcessing: false, + projectId: '', + onCancel: () => {} +} + +ModalAddLink.propTypes = { + classsName: PropTypes.string, + theme: PropTypes.shape(), + onCancel: PropTypes.func, + addAttachment: PropTypes.func.isRequired, + updateAttachment: PropTypes.func.isRequired, + link: PropTypes.shape(), + projectId: PropTypes.string +} + +const mapStateToProps = () => { + return {} +} + +const mapDispatchToProps = { + addAttachment, + updateAttachment +} + +export default connect(mapStateToProps, mapDispatchToProps)(ModalAddLink) diff --git a/src/components/AssetsLibrary/ModalAddLink/styles.module.scss b/src/components/AssetsLibrary/ModalAddLink/styles.module.scss new file mode 100644 index 00000000..8ff2a99d --- /dev/null +++ b/src/components/AssetsLibrary/ModalAddLink/styles.module.scss @@ -0,0 +1,51 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + border-radius: 6px; + margin: 0 auto; + padding: 15px 20px; + width: 600px; + max-width: 95vw; +} + +.button { + height: 40px; + + span, + button { + padding: 0 20px; + } +} + +.title { + @include roboto-medium(); + + color: grey; + font-size: 16px; +} + +.blockRow { + width: 100%; +} + +.blockForm { + display: flex; + flex-direction: column; + gap: 30px; + align-items: center; + margin-top: 30px; + margin-bottom: 10px; +} + +.blockBtns { + display: flex; + gap: 30px; +} diff --git a/src/components/AssetsLibrary/ModalAttachmentOptions/index.js b/src/components/AssetsLibrary/ModalAttachmentOptions/index.js new file mode 100644 index 00000000..e879746a --- /dev/null +++ b/src/components/AssetsLibrary/ModalAttachmentOptions/index.js @@ -0,0 +1,284 @@ +/* Component to render attachment options modal */ + +import React, { useEffect, useState, useRef, useCallback } from 'react' +import _ from 'lodash' +import PropTypes from 'prop-types' +import styles from './styles.module.scss' +import { useForm, Controller } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' +import { toastr } from 'react-redux-toastr' +import { connect } from 'react-redux' +import cn from 'classnames' +import Modal from '../../Modal' +import FieldUserAutoComplete from '../../FieldUserAutoComplete' +import FieldLabelDynamic from '../../FieldLabelDynamic' +import PrimaryButton from '../../Buttons/PrimaryButton' +import OutlineButton from '../../Buttons/OutlineButton' +import FieldInput from '../../FieldInput' +import { assetsLibraryEditFileSchema } from '../../../util/validation' +import { + addProjectAttachmentApi, + updateProjectAttachmentApi +} from '../../../services/projects' +import { addAttachment, updateAttachment } from '../../../actions/projects' + +const ModalAttachmentOptions = ({ + classsName, + theme, + onCancel, + attachment, + members, + addAttachment, + updateAttachment, + projectId, + loggedInUser, + newAttachments +}) => { + const [isProcessing, setIsProcessing] = useState(false) + const shareType = useRef('') + + const { + control, + register, + handleSubmit, + reset, + watch, + formState: { errors, isValid, isDirty } + } = useForm({ + defaultValues: { + allowedUsers: [] + }, + resolver: attachment ? yupResolver(assetsLibraryEditFileSchema) : null, + mode: 'all' + }) + + const allowedUsers = watch('allowedUsers') + + useEffect(() => { + if (attachment) { + reset({ + title: attachment.title, + allowedUsers: attachment.allowedUsers || [] + }) + } + }, [attachment]) + + const onEditSubmit = useCallback( + data => { + setIsProcessing(true) + updateProjectAttachmentApi(projectId, attachment.id, data) + .then(result => { + toastr.success('Success', 'Updated file successfully.') + setIsProcessing(false) + updateAttachment(result) + onCancel() + }) + .catch(e => { + setIsProcessing(false) + const errorMessage = _.get( + e, + 'response.data.message', + 'Failed to update file.' + ) + toastr.error('Error', errorMessage) + }) + }, + [attachment, projectId] + ) + + const onNewSubmit = useCallback( + allowedUsers => { + let count = newAttachments.length + let errorMessage = '' + const checkToFinish = () => { + count = count - 1 + if (count === 0) { + setIsProcessing(false) + if (errorMessage) { + toastr.error('Error', errorMessage) + } else { + toastr.success('Success', 'Added file to the project successfully.') + onCancel() + } + } + } + setIsProcessing(true) + _.forEach(newAttachments, newAttachment => { + addProjectAttachmentApi(projectId, { + ...newAttachment, + allowedUsers + }) + .then(result => { + addAttachment(result) + checkToFinish() + }) + .catch(e => { + errorMessage = _.get( + e, + 'response.data.message', + 'Failed to add file.' + ) + checkToFinish() + }) + }) + }, + [newAttachments, projectId] + ) + + return ( + +
+
+ {attachment ? 'EDIT FILE' : 'ATTACHMENT OPTIONS'} +
+
+ {!!attachment && ( + + + + )} +
+ {!attachment && ( + <> + + Who do you want to share this file with? + +
+ { + shareType.current = 'all' + onNewSubmit(null) + }} + disabled={isProcessing} + /> +
+ + )} +
+ {!attachment && ( + + OR ONLY SPECIFIC PEOPLE + + )} + + ( + + )} + name='allowedUsers' + /> + + {!attachment && ( +
+ { + shareType.current = 'selected' + onNewSubmit(allowedUsers) + }} + disabled={ + isProcessing || !allowedUsers || !allowedUsers.length + } + /> +
+ )} +
+
+ + {!!attachment && ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ ) +} + +ModalAttachmentOptions.defaultProps = { + members: [], + newAttachments: [], + projectId: '' +} + +ModalAttachmentOptions.propTypes = { + classsName: PropTypes.string, + theme: PropTypes.shape(), + onCancel: PropTypes.func, + attachment: PropTypes.shape(), + newAttachments: PropTypes.arrayOf(PropTypes.shape()), + members: PropTypes.arrayOf(PropTypes.shape()), + addAttachment: PropTypes.func.isRequired, + updateAttachment: PropTypes.func.isRequired, + projectId: PropTypes.string, + loggedInUser: PropTypes.object +} + +const mapStateToProps = () => { + return {} +} + +const mapDispatchToProps = { + addAttachment, + updateAttachment +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ModalAttachmentOptions) diff --git a/src/components/AssetsLibrary/ModalAttachmentOptions/styles.module.scss b/src/components/AssetsLibrary/ModalAttachmentOptions/styles.module.scss new file mode 100644 index 00000000..cef8ec1e --- /dev/null +++ b/src/components/AssetsLibrary/ModalAttachmentOptions/styles.module.scss @@ -0,0 +1,97 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + border-radius: 6px; + margin: 0 auto; + padding: 15px 20px; + width: 600px; + max-width: 95vw; +} + +.button { + height: 40px; + + span, + button { + padding: 0 20px; + } +} + +.title { + @include roboto-medium(); + + color: grey; + font-size: 16px; +} + +.blockRow { + width: 100%; +} + +.blockAddAttachment { + border-top: 1px solid $tc-gray-30; + display: flex; + flex-direction: column; + align-items: center; + margin-top: 20px; +} + +.textWhoYouWant { + @include roboto; + + width: 100%; + margin-top: 30px; + margin-bottom: 10px; + font-size: 16px; +} + +.blockForm { + display: flex; + flex-direction: column; + gap: 30px; + align-items: center; +} + +.blockFormEdit { + margin-top: 30px; + margin-bottom: 10px; +} + +.blockSelectMember { + width: calc(100% + 40px); + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + padding: 10px; + background: $tc-gray-20; + border-top: 1px solid $tc-gray-30; + margin: 10px -20px -15px -20px; + border-radius: 0 0 10px 10px; +} + +.textOrOnly { + @include roboto-medium(); + + font-size: 11px; + color: grey; + text-transform: uppercase; +} + +.btnShareWith { + margin-top: 10px; + margin-bottom: 30px; +} + +.blockBtns { + display: flex; + gap: 30px; +} diff --git a/src/components/AssetsLibrary/ProjectMember/index.js b/src/components/AssetsLibrary/ProjectMember/index.js new file mode 100644 index 00000000..f129b17f --- /dev/null +++ b/src/components/AssetsLibrary/ProjectMember/index.js @@ -0,0 +1,33 @@ +/* Component to render project member */ + +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import styles from './styles.module.scss' +import cn from 'classnames' +import { PROFILE_URL } from '../../../config/constants' +import { getFullNameWithFallback } from '../../../util/tc' + +const ProjectMember = ({ classsName, memberInfo }) => { + const fullName = useMemo(() => getFullNameWithFallback(memberInfo), [ + memberInfo + ]) + return ( + + {fullName} + + ) +} + +ProjectMember.defaultProps = {} + +ProjectMember.propTypes = { + classsName: PropTypes.string, + memberInfo: PropTypes.shape() +} + +export default ProjectMember diff --git a/src/components/AssetsLibrary/ProjectMember/styles.module.scss b/src/components/AssetsLibrary/ProjectMember/styles.module.scss new file mode 100644 index 00000000..4ac6ceeb --- /dev/null +++ b/src/components/AssetsLibrary/ProjectMember/styles.module.scss @@ -0,0 +1,5 @@ +@import '../../../styles/includes'; + +.container { + display: flex; +} \ No newline at end of file diff --git a/src/components/AssetsLibrary/ProjectMembers/index.js b/src/components/AssetsLibrary/ProjectMembers/index.js new file mode 100644 index 00000000..ad9eeb6c --- /dev/null +++ b/src/components/AssetsLibrary/ProjectMembers/index.js @@ -0,0 +1,60 @@ +/* Component to render list of project member */ + +import React, { useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' +import styles from './styles.module.scss' +import ProjectMember from '../ProjectMember' +import cn from 'classnames' + +const ProjectMembers = ({ classsName, members, allowedUsers, maxShownNum }) => { + const [showAll, setShowAll] = useState(false) + const allowedUserInfos = useMemo(() => { + const results = _.uniqBy( + _.compact(allowedUsers.map(userId => _.find(members, { userId }))), + 'userId' + ) + let extra = 0 + const maxUsers = [...results] + if (maxUsers.length > maxShownNum) { + extra = results.length - maxShownNum + maxUsers.length = maxShownNum + } + + return { + all: results, + maxUsers, + extra + } + }, [members, allowedUsers, maxShownNum]) + + return ( +
+ {(showAll ? allowedUserInfos.all : allowedUserInfos.maxUsers).map( + item => ( + + ) + )} + {!showAll && allowedUserInfos.extra !== 0 && ( + + )} +
+ ) +} + +ProjectMembers.defaultProps = { + maxShownNum: 3, + allowedUsers: [], + members: [] +} + +ProjectMembers.propTypes = { + classsName: PropTypes.string, + maxShownNum: PropTypes.number, + allowedUsers: PropTypes.arrayOf(PropTypes.number), + members: PropTypes.arrayOf(PropTypes.shape()) +} + +export default ProjectMembers diff --git a/src/components/AssetsLibrary/ProjectMembers/styles.module.scss b/src/components/AssetsLibrary/ProjectMembers/styles.module.scss new file mode 100644 index 00000000..16cefcce --- /dev/null +++ b/src/components/AssetsLibrary/ProjectMembers/styles.module.scss @@ -0,0 +1,17 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.btn { + border: none; + background-color: transparent; + margin: 0; + outline: none !important; + border: 1px solid $tc-gray-30; + border-radius: 6px; + padding: 0 3px; +} \ No newline at end of file diff --git a/src/components/AssetsLibrary/TabCommon/index.js b/src/components/AssetsLibrary/TabCommon/index.js new file mode 100644 index 00000000..2b319c04 --- /dev/null +++ b/src/components/AssetsLibrary/TabCommon/index.js @@ -0,0 +1,49 @@ +/* Component to render tab ui */ + +import React from 'react' +import PropTypes from 'prop-types' +import styles from './styles.module.scss' +import cn from 'classnames' + +const TabCommon = ({ items, classsName, selectedIndex, onSelect }) => { + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ) +} + +TabCommon.defaultProps = { + items: [], + classsName: '', + onSelect: () => {} +} + +TabCommon.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + count: PropTypes.number + }) + ), + classsName: PropTypes.string, + selectedIndex: PropTypes.number, + onSelect: PropTypes.func +} + +export default TabCommon diff --git a/src/components/AssetsLibrary/TabCommon/styles.module.scss b/src/components/AssetsLibrary/TabCommon/styles.module.scss new file mode 100644 index 00000000..191cee55 --- /dev/null +++ b/src/components/AssetsLibrary/TabCommon/styles.module.scss @@ -0,0 +1,50 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + gap: 20px; +} + +.blockTab { + padding: 0 15px; + margin: 0; + border: none; + background: transparent; + display: flex; + border-radius: 20px; + height: 30px; + align-items: flex-end; + gap: 5px; + outline: none !important; + + &.selected { + background-color: $tc-gray-30; + + * { + font-weight: 700; + } + .textCount { + background-color: $tc-gray-80; + } + } +} + +.textLabel { + @include roboto; + + font-size: 16px; + line-height: 30px; + color: $tc-gray-90; +} + +.textCount { + @include roboto; + + background-color: grey; + border-radius: 9px; + color: #fff; + font-size: 10px; + line-height: 12px; + padding: 0 4px; + margin-bottom: 7px; +} diff --git a/src/components/AssetsLibrary/TableAssets/index.js b/src/components/AssetsLibrary/TableAssets/index.js new file mode 100644 index 00000000..e5dc40c4 --- /dev/null +++ b/src/components/AssetsLibrary/TableAssets/index.js @@ -0,0 +1,158 @@ +/* Component to render assets table */ + +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' +import moment from 'moment' +import styles from './styles.module.scss' +import Table from '../../Table' +import IconThreeDot from '../../Icons/IconThreeDot' +import DropdownMenu from '../../DropdownMenu' +import DownloadFile from '../DownloadFile' +import IconFile from '../../Icons/IconFile' +import cn from 'classnames' +import { + PROJECT_ASSETS_SHARED_WITH_ALL_MEMBERS, + PROJECT_ASSETS_SHARED_WITH_ADMIN +} from '../../../config/constants' +import ProjectMembers from '../ProjectMembers' +import ProjectMember from '../ProjectMember' + +const TableAssets = ({ + classsName, + title, + onEdit, + onRemove, + datas, + isLink, + projectId, + members, + loggedInUser, + isAdmin +}) => { + const displayAssets = useMemo( + () => + datas.map(item => { + const titles = item.title.split('.') + const owner = _.find(members, { userId: item.createdBy }) + const canEdit = + `${item.createdBy}` === `${loggedInUser.userId}` || isAdmin + return { + ...item, + fileType: titles[titles.length - 1], + owner, + updatedAtString: item.updatedAt + ? moment(item.updatedAt).format('MM/DD/YYYY h:mm A') + : '—', + canEdit + } + }), + [datas, members, loggedInUser, isAdmin] + ) + return ( +
+ {title} + + ( + + + + + + {isLink ? ( + + {item.title} + + ) : ( + + )} + + + {!item.allowedUsers && PROJECT_ASSETS_SHARED_WITH_ALL_MEMBERS} + {item.allowedUsers && + item.allowedUsers === 0 && + PROJECT_ASSETS_SHARED_WITH_ADMIN} + {item.allowedUsers && item.allowedUsers !== 0 && ( + + )} + + + {!item.owner && !item.createdBy && '—'} + {!item.owner && item.createdBy !== 'CoderBot' && 'Unknown'} + {!item.owner && item.createdBy === 'CoderBot' && 'CoderBot'} + {!!item.owner && } + + + {item.updatedAtString} + + + {item.canEdit && ( + { + if (menu === 'Edit') { + onEdit(item) + } else if (menu === 'Remove') { + onRemove(item) + } + }} + options={['Edit', 'Remove']} + > + + + )} + + + ))} + /> + + ) +} + +TableAssets.defaultProps = { + title: '', + onEdit: () => {}, + onRemove: () => {}, + datas: [], + isLink: false, + isAdmin: false, + members: [] +} + +TableAssets.propTypes = { + classsName: PropTypes.string, + projectId: PropTypes.string, + title: PropTypes.string, + isAdmin: PropTypes.bool, + onEdit: PropTypes.func, + onRemove: PropTypes.func, + datas: PropTypes.arrayOf(PropTypes.shape()), + members: PropTypes.arrayOf(PropTypes.shape()), + isLink: PropTypes.bool, + loggedInUser: PropTypes.object +} + +export default TableAssets diff --git a/src/components/AssetsLibrary/TableAssets/styles.module.scss b/src/components/AssetsLibrary/TableAssets/styles.module.scss new file mode 100644 index 00000000..f719e5c7 --- /dev/null +++ b/src/components/AssetsLibrary/TableAssets/styles.module.scss @@ -0,0 +1,34 @@ +@import '../../../styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.textTitle { + color: $tc-gray-90; + @include roboto-bold(); + + font-size: 16px; + line-height: 20px; +} + +.colMenu { + width: 50px; +} + +.blockItem { + @include roboto; + + font-size: 14px; + color: $tc-gray-90; + + a { + color: $tc-gray-90; + + &:hover { + text-decoration: none; + } + } +} diff --git a/src/components/AssetsLibrary/index.js b/src/components/AssetsLibrary/index.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/ChallengeEditor/ArtifactsListModal/ArtifactsListModal.module.scss b/src/components/ChallengeEditor/ArtifactsListModal/ArtifactsListModal.module.scss new file mode 100644 index 00000000..912b3731 --- /dev/null +++ b/src/components/ChallengeEditor/ArtifactsListModal/ArtifactsListModal.module.scss @@ -0,0 +1,53 @@ +@import "../../../styles/includes"; + +.container { + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + border-radius: 6px; + margin: 0 auto; + width: 800px; + min-height: 350px; + padding-top: 60px; + .list { + .header { + border-bottom: 1px solid $tc-gray-60; + padding-bottom: 10px; + display: flex; + padding-left: 40px; + padding-right: 40px; + color: $tc-gray-70; + font-weight: 500; + + .header-title { + flex: 1; + } + } + .list-item { + border-bottom: 1px solid $tc-gray-60; + padding-bottom: 10px; + padding-top: 10px; + display: flex; + padding-left: 40px; + padding-right: 40px; + color: $tc-gray-70; + .artifact-name { + display: flex; + flex: 1; + } + .icon-download { + cursor: pointer; + } + } + .no-artifacts { + @include roboto; + + margin-top: 40px; + text-align: center; + } + } +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/ArtifactsListModal/index.js b/src/components/ChallengeEditor/ArtifactsListModal/index.js new file mode 100644 index 00000000..2aa23b99 --- /dev/null +++ b/src/components/ChallengeEditor/ArtifactsListModal/index.js @@ -0,0 +1,115 @@ +import React, { useCallback, useEffect, useState } from 'react' +import Modal from '../../Modal' + +import styles from './ArtifactsListModal.module.scss' +import PropTypes from 'prop-types' +import ReactSVG from 'react-svg' +import { getTopcoderReactLib, isValidDownloadFile } from '../../../util/topcoder-react-lib' +import Loader from '../../Loader' +const assets = require.context('../../../assets/images', false, /svg/) + +export const ArtifactsListModal = ({ onClose, submissionId, token, theme }) => { + const [artifacts, setArtifacts] = useState([]) + const [loading, setLoading] = useState(false) + + const getArtifacts = useCallback(async () => { + const reactLib = getTopcoderReactLib() + const { getService } = reactLib.services.submissions + const submissionsService = getService(token) + const { artifacts: resp } = await submissionsService.getSubmissionArtifacts(submissionId) + setArtifacts(resp) + setLoading(false) + }, [submissionId, token]) + + const getExtensionFromMime = useCallback((mimeType) => { + const mimeMap = { + 'application/zip': 'zip', + 'application/pdf': 'pdf', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'text/plain': 'txt' + } + return mimeMap[mimeType] || 'zip' + }, []) + + useEffect(() => { + setLoading(true) + getArtifacts() + }, [submissionId]) + + const onDownloadArtifact = useCallback((item) => { + // download submission + const reactLib = getTopcoderReactLib() + const { getService } = reactLib.services.submissions + const submissionsService = getService(token) + submissionsService.downloadSubmissionArtifact(submissionId, item) + .then((blob) => { + isValidDownloadFile(blob).then((isValidFile) => { + if (isValidFile.success) { + // eslint-disable-next-line no-undef + const blobFile = new Blob([blob]) + const url = window.URL.createObjectURL(blobFile) + const link = document.createElement('a') + link.href = url + const extension = getExtensionFromMime(blob.type) + const fileName = `${submissionId}.${extension}` + link.setAttribute('download', `${fileName}`) + document.body.appendChild(link) + link.click() + link.parentNode.removeChild(link) + } else { + console.log('failed to download artifact') + } + }) + }) + }, [submissionId, token]) + + return ( + +
+
+
+
Artifact ID
+
Action
+
+ { + !loading && artifacts.map((item) => { + return ( +
+
{item}
+ onDownloadArtifact(item)} + /> +
+ ) + }) + } + + { + !loading && artifacts.length === 0 &&
No artifacts found
+ } + + { + loading && + } +
+
+
+ ) +} + +ArtifactsListModal.defaultProps = { + onClose: () => {}, + submissionId: '', + token: '', + theme: '' +} + +ArtifactsListModal.propTypes = { + onClose: PropTypes.func, + submissionId: PropTypes.string, + token: PropTypes.string, + theme: PropTypes.shape() +} diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index 8b658549..430e22bf 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import _ from 'lodash' import PropTypes from 'prop-types' import cn from 'classnames' -import { withRouter } from 'react-router-dom' +import { withRouter, Link } from 'react-router-dom' import styles from './ChallengeView.module.scss' import Track from '../../Track' import NDAField from '../NDAField' @@ -20,7 +20,6 @@ import { getResourceRoleByName } from '../../../util/tc' import { loadGroupDetails } from '../../../actions/challenges' import { REVIEW_TYPES, - CONNECT_APP_URL, PHASE_PRODUCT_CHALLENGE_ID_FIELD, MULTI_ROUND_CHALLENGE_TEMPLATE_ID, DS_TRACK_ID @@ -113,17 +112,15 @@ const ChallengeView = ({
- Project: {projectDetail ? projectDetail.name : ''} + Project: + {projectDetail ? projectDetail.name : ''} + +
{selectedMilestone &&
- Milestone: {selectedMilestone ? ( - - {selectedMilestone.name} - - ) : ''} + Milestone: {selectedMilestone ? selectedMilestone.name : ''}
}
diff --git a/src/components/ChallengeEditor/ChallengeViewTabs/index.js b/src/components/ChallengeEditor/ChallengeViewTabs/index.js index 84ee5676..9c7d301f 100644 --- a/src/components/ChallengeEditor/ChallengeViewTabs/index.js +++ b/src/components/ChallengeEditor/ChallengeViewTabs/index.js @@ -55,7 +55,11 @@ const ChallengeViewTabs = ({ loggedInUser, onApproveChallenge, createResource, - deleteResource + deleteResource, + loadSubmissions, + totalSubmissions, + submissionsPerPage, + page }) => { const [selectedTab, setSelectedTab] = useState(0) const [showAddResourceModal, setShowAddResourceModal] = useState(false) @@ -114,7 +118,7 @@ const ChallengeViewTabs = ({ }) return s }) - }, [challengeSubmissions, allResources]) + }, [challengeSubmissions, allResources, page]) const isTask = _.get(challenge, 'task.isTask', false) @@ -298,7 +302,7 @@ const ChallengeViewTabs = ({ > RESOURCES - {challengeSubmissions.length ? ( + {totalSubmissions ? ( - SUBMISSIONS ({submissions.length}) + SUBMISSIONS ({totalSubmissions}) ) : null}
@@ -353,6 +357,10 @@ const ChallengeViewTabs = ({ submissions={submissions} token={token} loggedInUserResource={loggedInUserResource} + loadSubmissions={loadSubmissions} + totalSubmissions={totalSubmissions} + submissionsPerPage={submissionsPerPage} + page={page} /> )} {showAddResourceModal ? ( { const options = milestones.map(type => ({ label: type.name, value: type.id })) @@ -28,10 +26,6 @@ const MilestoneField = ({ milestones, onUpdateSelect, disabled, projectId, selec isDisabled={disabled} />
- - - ) diff --git a/src/components/ChallengeEditor/RatingsListModal/RatingsListModal.module.scss b/src/components/ChallengeEditor/RatingsListModal/RatingsListModal.module.scss new file mode 100644 index 00000000..2a1c49e6 --- /dev/null +++ b/src/components/ChallengeEditor/RatingsListModal/RatingsListModal.module.scss @@ -0,0 +1,44 @@ +@import "../../../styles/includes"; + +.container { + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + border-radius: 6px; + margin: 0 auto; + width: 800px; + min-height: 350px; + padding-top: 60px; + .list { + .header { + border-bottom: 1px solid $tc-gray-60; + padding-bottom: 10px; + display: flex; + padding-left: 40px; + padding-right: 40px; + color: $tc-gray-70; + font-weight: 500; + + .header-item { + flex: 1; + } + } + .list-item { + border-bottom: 1px solid $tc-gray-60; + padding-bottom: 10px; + padding-top: 10px; + display: flex; + padding-left: 40px; + padding-right: 40px; + color: $tc-gray-70; + .list-col-item { + display: flex; + flex: 1; + } + } + } +} \ No newline at end of file diff --git a/src/components/ChallengeEditor/RatingsListModal/index.js b/src/components/ChallengeEditor/RatingsListModal/index.js new file mode 100644 index 00000000..33f17e94 --- /dev/null +++ b/src/components/ChallengeEditor/RatingsListModal/index.js @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect, useState } from 'react' +import Modal from '../../Modal' + +import styles from './RatingsListModal.module.scss' +import PropTypes from 'prop-types' +import { getTopcoderReactLib } from '../../../util/topcoder-react-lib' +import Loader from '../../Loader' +import { getReviewTypes } from '../../../services/challenges' +import { SystemReviewers } from '../../../config/constants' + +export const RatingsListModal = ({ onClose, theme, token, submissionId, challengeId }) => { + const [reviews, setReviews] = useState([]) + const [loading, setLoading] = useState(false) + + const enrichSources = useCallback(async (submissionReviews, reviewSummation) => { + const reactLib = getTopcoderReactLib() + const { getService } = reactLib.services.members + const membersService = getService(token) + const resources = await membersService.getChallengeResources(challengeId) + const reviewTypes = await getReviewTypes() + + const finalReview = { + reviewType: 'Final score', + reviewer: '', + score: reviewSummation ? reviewSummation.aggregateScore : 'N/A', + isPassing: reviewSummation ? reviewSummation.isPassing : undefined + } + + return [...submissionReviews.map(review => { + const reviewType = reviewTypes.find(rt => rt.id === review.typeId) + const reviewer = resources.find(resource => resource.memberHandle === review.reviewerId) || SystemReviewers.Default + return { + ...review, + reviewType: reviewType ? reviewType.name : '', + reviewer + } + }), finalReview] + }, [token]) + + const getSubmission = useCallback(async () => { + const reactLib = getTopcoderReactLib() + const { getService } = reactLib.services.submissions + const submissionsService = getService(token) + const submissionInfo = await submissionsService.getSubmissionInformation(submissionId) + setReviews(await enrichSources(submissionInfo.review, submissionInfo.reviewSummation[0])) + setLoading(false) + }, [submissionId, token]) + + useEffect(() => { + setLoading(true) + getSubmission() + }, [submissionId]) + + return ( + +
+
+
+
Review Type
+
Reviewer
+
Score
+
Status
+
+ {reviews.map(review => { + const { isPassing } = review + const isFailed = isPassing === false + const isPassed = isPassing === true + const statusIsDefined = isPassed || isFailed + const status = isPassing ? 'Passed' : 'Failed' + + return ( +
+
+ {review.reviewType} +
+
+ {review.reviewer} +
+
+ {review.score} +
+
+ {statusIsDefined ? status : 'N/A'} +
+
+ ) + })} +
+ + { + loading && + } +
+
+ ) +} + +RatingsListModal.defaultProps = { + onClose: () => {}, + theme: '', + token: '', + submissionId: '', + challengeId: '' +} + +RatingsListModal.propTypes = { + onClose: PropTypes.func, + theme: PropTypes.shape(), + token: PropTypes.string, + submissionId: PropTypes.string, + challengeId: PropTypes.string +} diff --git a/src/components/ChallengeEditor/Submissions/Submissions.module.scss b/src/components/ChallengeEditor/Submissions/Submissions.module.scss index 59149d21..966452fc 100644 --- a/src/components/ChallengeEditor/Submissions/Submissions.module.scss +++ b/src/components/ChallengeEditor/Submissions/Submissions.module.scss @@ -3,7 +3,6 @@ $tc-black : #151516; $tc-gray-10: #d5d5d5; $tc-gray-50: #808080; -$tc-gray-90: #2a2a2b; $tc-light-blue: #15acec; $tc-dark-blue: #0681ff; $tc-dark-blue-110: #006ad7; @@ -240,11 +239,24 @@ $base-unit: 5px; } .col-8Table { - button { + .button-wrapper { + display: flex; + align-items: center; + } + .download-submission-button { padding: 0; border: none; background-color: transparent; outline: none; + margin-right: 14px; + width: 24px; + svg { + width: 24px; + } + } + + .download-artifacts-button { + height: 40px; } } @@ -389,3 +401,25 @@ $base-unit: 5px; overflow: hidden; } } + +.paginationWrapper { + display: flex; + margin-top: 30px; + flex: 1; + justify-content: center; +} + +.footer { + display: flex; + justify-content: flex-end; + margin-top: 30px; +} + +.perPageContainer { + margin-right: 20px; + max-width: 150px; +} + +.paginationContainer { + display: flex; +} diff --git a/src/components/ChallengeEditor/Submissions/index.js b/src/components/ChallengeEditor/Submissions/index.js index 13428040..e3b71051 100644 --- a/src/components/ChallengeEditor/Submissions/index.js +++ b/src/components/ChallengeEditor/Submissions/index.js @@ -7,16 +7,13 @@ import React from 'react' import PT from 'prop-types' import moment from 'moment' import _ from 'lodash' -import { STUDIO_URL, SUBMISSION_REVIEW_APP_URL, getTCMemberURL } from '../../../config/constants' +import { PAGINATION_PER_PAGE_OPTIONS, STUDIO_URL, SUBMISSION_REVIEW_APP_URL, getTCMemberURL } from '../../../config/constants' import { PrimaryButton } from '../../Buttons' import AlertModal from '../../Modal/AlertModal' import cn from 'classnames' import ReactSVG from 'react-svg' import { getRatingLevel, - sortList, - getProvisionalScore, - getFinalScore, checkDownloadSubmissionRoles, checkAdmin } from '../../../util/tc' @@ -29,10 +26,16 @@ import { } from '../../../util/files' import styles from './Submissions.module.scss' import modalStyles from '../../../styles/modal.module.scss' +import { ArtifactsListModal } from '../ArtifactsListModal' +import Tooltip from '../../Tooltip' +import { RatingsListModal } from '../RatingsListModal' +import Select from '../../Select' +import Pagination from 'react-js-pagination' const assets = require.context('../../../assets/images', false, /svg/) -const ArrowDown = './arrow-down.svg' const Lock = './lock.svg' const Download = './IconSquareDownload.svg' +const DownloadArtifact = './IconDownloadArtifacts.svg' +const ReviewRatingList = './IconReviewRatingList.svg' const theme = { container: modalStyles.modalContainer @@ -48,157 +51,21 @@ class SubmissionsComponent extends React.Component { }, isShowInformation: false, memberOfModal: '', - sortedSubmissions: [], + submissions: [], downloadingAll: false, - alertMessage: '' + alertMessage: '', + selectedSubmissionId: '', + showArtifactsListModal: false, + showRatingsListModal: false } - this.getSubmissionsSortParam = this.getSubmissionsSortParam.bind(this) - this.updateSortedSubmissions = this.updateSortedSubmissions.bind(this) - this.sortSubmissions = this.sortSubmissions.bind(this) - this.onSortChange = this.onSortChange.bind(this) this.checkIsReviewPhaseComplete = this.checkIsReviewPhaseComplete.bind( this ) + this.downloadSubmission = this.downloadSubmission.bind(this) + this.handlePageChange = this.handlePageChange.bind(this) + this.handlePerPageChange = this.handlePerPageChange.bind(this) } - componentDidMount () { - this.updateSortedSubmissions() - } - - /** - * Get submission sort parameter - */ - getSubmissionsSortParam () { - const { submissionsSort } = this.state - let { field, sort } = submissionsSort - if (!field) { - field = 'Submission Date' // default field for submission sorting - } - - if (!sort) { - sort = 'asc' // default order for submission sorting - } - - return { - field, - sort - } - } - - /** - * Update sorted submission array - */ - updateSortedSubmissions () { - const { submissions } = this.props - const sortedSubmissions = _.cloneDeep(submissions) - this.sortSubmissions(sortedSubmissions) - this.setState({ sortedSubmissions }) - } - - /** - * Sort array of submission - * @param {Array} submissions array of submission - */ - sortSubmissions (submissions) { - const { field, sort } = this.getSubmissionsSortParam() - let isHaveFinalScore = false - if (field === 'Initial / Final Score') { - isHaveFinalScore = _.some( - submissions, - s => !_.isNil(s.reviewSummation && s.reviewSummation[0].aggregateScore) - ) - } - return sortList(submissions, field, sort, (a, b) => { - let valueA = 0 - let valueB = 0 - let valueIsString = false - switch (field) { - case 'Country': { - valueA = a.registrant ? a.registrant.countryCode : '' - valueB = b.registrant ? b.registrant.countryCode : '' - valueIsString = true - break - } - case 'Rating': { - valueA = a.registrant ? a.registrant.rating : 0 - valueB = b.registrant ? b.registrant.rating : 0 - break - } - case 'Username': { - valueA = _.get(a.registrant, 'memberHandle', '').toLowerCase() - valueB = _.get(b.registrant, 'memberHandle', '').toLowerCase() - valueIsString = true - break - } - case 'Email': { - valueA = _.get(a.registrant, 'email', '').toLowerCase() - valueB = _.get(b.registrant, 'email', '').toLowerCase() - valueIsString = true - break - } - case 'Time': - valueA = new Date(a.submissions && a.submissions[0].submissionTime) - valueB = new Date(b.submissions && b.submissions[0].submissionTime) - break - case 'Submission Date': { - valueA = new Date(a.created) - valueB = new Date(b.created) - break - } - case 'Initial / Final Score': { - if (isHaveFinalScore) { - valueA = getFinalScore(a) - valueB = getFinalScore(b) - } else { - valueA = !_.isEmpty(a.review) && a.review[0].score - valueB = !_.isEmpty(b.review) && b.review[0].score - } - break - } - case 'Final Rank': { - if (this.checkIsReviewPhaseComplete()) { - valueA = a.finalRank ? a.finalRank : 0 - valueB = b.finalRank ? b.finalRank : 0 - } - break - } - case 'Provisional Rank': { - valueA = a.provisionalRank ? a.provisionalRank : 0 - valueB = b.provisionalRank ? b.provisionalRank : 0 - break - } - case 'Final Score': { - valueA = getFinalScore(a) - valueB = getFinalScore(b) - break - } - case 'Provisional Score': { - valueA = getProvisionalScore(a) - valueB = getProvisionalScore(b) - break - } - default: - } - - if (valueIsString === false) { - if (valueA === '-') valueA = 0 - if (valueB === '-') valueB = 0 - } - - return { - valueA, - valueB, - valueIsString - } - }) - } - - onSortChange (sort) { - this.setState({ - submissionsSort: sort - }) - this.updateSortedSubmissions() - } /** * Check if review phase complete */ @@ -220,17 +87,96 @@ class SubmissionsComponent extends React.Component { return isReviewPhaseComplete } + closeArtifactsModal () { + this.setState({ + selectedSubmissionId: '', + showArtifactsListModal: false + }) + } + + async downloadSubmission (submission) { + // download submission + const reactLib = getTopcoderReactLib() + const { getService } = reactLib.services.submissions + const submissionsService = getService(this.props.token) + submissionsService.downloadSubmission(submission.id) + .then((blob) => { + isValidDownloadFile(blob).then((isValidFile) => { + if (isValidFile.success) { + // eslint-disable-next-line no-undef + const url = window.URL.createObjectURL(new Blob([blob])) + const link = document.createElement('a') + link.href = url + let fileName = submission.legacySubmissionId + if (!fileName) { + fileName = submission.id + } + fileName = fileName + '.zip' + link.setAttribute('download', `${fileName}`) + document.body.appendChild(link) + link.click() + link.parentNode.removeChild(link) + } else { + this.setState({ + alertMessage: isValidFile.message || 'Can not download this submission.' + }) + } + }) + }) + } + + /** + * Update filter for getting project by pagination + * @param {Number} perPageNumber per page number + */ + handlePerPageChange (option) { + const perPageNumber = option.value + const { + submissionsPerPage, + loadSubmissions, + challenge: { + id + } + } = this.props + + if (submissionsPerPage !== perPageNumber) { + loadSubmissions(id, { + page: 1, + perPage: perPageNumber + }) + } + } + + /** + * Update filter for getting project by pagination + * @param {Number} pageNumber page number + */ + async handlePageChange (pageNumber) { + const { + page, + submissionsPerPage, + loadSubmissions, + challenge: { + id + } + } = this.props + + if (page !== pageNumber) { + loadSubmissions(id, { + page: pageNumber, + perPage: submissionsPerPage + }) + } + } + render () { - const { challenge, token, loggedInUserResource } = this.props + const { challenge, token, loggedInUserResource, page, submissionsPerPage, totalSubmissions, submissions } = this.props const { checkpoints, track, type, tags } = challenge const canDownloadSubmission = (loggedInUserResource && checkDownloadSubmissionRoles(loggedInUserResource.roles)) || checkAdmin(token) - const { field, sort } = this.getSubmissionsSortParam() - const revertSort = sort === 'desc' ? 'asc' : 'desc' - - const { sortedSubmissions, downloadingAll, alertMessage } = this.state + const { downloadingAll, alertMessage } = this.state const renderSubmission = s => (
@@ -272,7 +218,7 @@ class SubmissionsComponent extends React.Component { const isBugHunt = _.includes(tags, 'Bug Hunt') // copy colorStyle from registrants to submissions - _.forEach(sortedSubmissions, s => { + _.forEach(submissions, s => { if (s.registrant && s.registrant.colorStyle && !s.colorStyle) { const { colorStyle } = s.registrant /* eslint-disable no-param-reassign */ @@ -297,7 +243,7 @@ class SubmissionsComponent extends React.Component {
ROUND 2 (FINAL) SUBMISSIONS
- {sortedSubmissions.map(renderSubmission)} + {submissions.map(renderSubmission)}
{checkpoints.length > 0 && (
@@ -341,117 +287,20 @@ class SubmissionsComponent extends React.Component {
{!isF2F && !isBugHunt && ( )} - {sortedSubmissions.map(s => { + {submissions.map(s => { const rating = s.registrant && !_.isNil(s.registrant.rating) ? s.registrant.rating : '-' @@ -544,40 +393,38 @@ class SubmissionsComponent extends React.Component { {canDownloadSubmission ? () : null} ) @@ -586,6 +433,36 @@ class SubmissionsComponent extends React.Component {
- + Rating - + Username - + Email - + Submission Date - + Initial / Final Score
- +
+ + + + + + + + + + + +
+ { + this.state.showArtifactsListModal ? ( + { + this.setState({ + selectedSubmissionId: '', + showArtifactsListModal: false + }) + }} + /> + ) : null + } + + { + this.state.showRatingsListModal ? ( + { + this.setState({ showRatingsListModal: false }) + }} + submissionId={this.state.selectedSubmissionId} + challengeId={this.props.challenge.id} + /> + ) : null + } + {canDownloadSubmission ? (
@@ -604,7 +481,7 @@ class SubmissionsComponent extends React.Component { const allFiles = [] let downloadedFile = 0 const checkToCompressFiles = () => { - if (downloadedFile === sortedSubmissions.length) { + if (downloadedFile === submissions.length) { if (downloadedFile > 0) { compressFiles(allFiles, 'all-submissions.zip', () => { this.setState({ @@ -619,7 +496,7 @@ class SubmissionsComponent extends React.Component { } } checkToCompressFiles() - _.forEach(sortedSubmissions, (submission) => { + _.forEach(submissions, (submission) => { let fileName = submission.legacySubmissionId if (!fileName) { fileName = submission.id @@ -646,6 +523,30 @@ class SubmissionsComponent extends React.Component {
) : null}
+
+
+ @@ -854,6 +862,7 @@ ChallengeList.defaultProps = { ChallengeList.propTypes = { challenges: PropTypes.arrayOf(PropTypes.object), + fetchNextProjects: PropTypes.func.isRequired, projects: PropTypes.arrayOf(PropTypes.object), activeProject: PropTypes.shape({ id: PropTypes.number, diff --git a/src/components/ChallengesComponent/ProjectStatus/index.js b/src/components/ChallengesComponent/ProjectStatus/index.js index 1fa9b837..9592c53f 100644 --- a/src/components/ChallengesComponent/ProjectStatus/index.js +++ b/src/components/ChallengesComponent/ProjectStatus/index.js @@ -1,13 +1,13 @@ import React from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import { PROJECT_STATUS } from '../../../config/constants' +import { PROJECT_STATUSES } from '../../../config/constants' import styles from './ProjectStatus.module.scss' const ProjectStatus = ({ status }) => { return (
-
{PROJECT_STATUS.find(item => item.value === status).label}
+
{PROJECT_STATUSES.find(item => item.value === status).label}
) } diff --git a/src/components/ChallengesComponent/index.js b/src/components/ChallengesComponent/index.js index 46166fc2..dd932641 100644 --- a/src/components/ChallengesComponent/index.js +++ b/src/components/ChallengesComponent/index.js @@ -7,11 +7,11 @@ import PropTypes from 'prop-types' import { Helmet } from 'react-helmet' import { Link } from 'react-router-dom' import ProjectStatus from './ProjectStatus' -import { PROJECT_ROLES, TYPEFORM_URL } from '../../config/constants' +import { PROJECT_ROLES, PROJECT_STATUS, COPILOTS_URL } from '../../config/constants' import { PrimaryButton, OutlineButton } from '../Buttons' import ChallengeList from './ChallengeList' import styles from './ChallengesComponent.module.scss' -import { checkAdmin, checkReadOnlyRoles, checkAdminOrCopilot } from '../../util/tc' +import { checkAdmin, checkReadOnlyRoles, checkAdminOrCopilot, checkManager } from '../../util/tc' const ChallengesComponent = ({ challenges, @@ -46,10 +46,12 @@ const ChallengesComponent = ({ isBillingAccountLoading, selfService, auth, - challengeTypes + challengeTypes, + fetchNextProjects }) => { const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ + const isAdminOrCopilot = checkAdminOrCopilot(auth.token, activeProject) useEffect(() => { const loggedInUser = auth.user @@ -69,7 +71,7 @@ const ChallengesComponent = ({ {activeProject ? activeProject.name : ''} {activeProject && activeProject.status && }
- {activeProject && activeProject.id && checkAdminOrCopilot(auth.token) && ( + {activeProject && activeProject.id && isAdminOrCopilot && ( ( {activeProject && activeProject.id && !isReadOnly ? (
- {checkAdmin(auth.token) && ( + {isAdminOrCopilot && ( + + )} + {(checkAdmin(auth.token) || checkManager(auth.token)) && ( )} - - - + {activeProject.status === PROJECT_STATUS.ACTIVE ? ( + + + + ) : ( + + )}
) : ( @@ -104,6 +119,7 @@ const ChallengesComponent = ({
{ + const menu = ( +
+ {_.map(options, r => { + return ( +
{ + onSelectMenu(r) + }} + > + {r} +
+ ) + })} +
+ ) + + return ( + + + + ) +} + +DropdownMenu.defaultProps = { + onSelectMenu: () => {}, + options: [] +} + +DropdownMenu.propTypes = { + onSelectMenu: PropTypes.func.isRequired, + children: PropTypes.node, + options: PropTypes.arrayOf(PropTypes.string) +} + +export default DropdownMenu diff --git a/src/components/DropdownMenu/styles.module.scss b/src/components/DropdownMenu/styles.module.scss new file mode 100644 index 00000000..a504c35b --- /dev/null +++ b/src/components/DropdownMenu/styles.module.scss @@ -0,0 +1,37 @@ +@import "../../styles/includes"; + +.menus { + outline: none; + position: relative; + list-style-type: none; + padding: 10px 0; + margin: 2px 0 0 0; + text-align: left; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 10px rgba(21, 21, 22, .2); + border: none; + background-clip: padding-box; + cursor: pointer; + min-width: 150px; + min-height: 45px; + + .menu { + height: 30px; + line-height: 30px; + padding: 0 10px 0 20px; + font-size: 13px; + + &:hover { + background-color: rgba($tc-dark-blue-10, .5); + } + } +} + +.btn { + border: none; + background-color: transparent; + outline: none !important; + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/src/components/FieldInput/index.js b/src/components/FieldInput/index.js index 926778f1..a75cb1ba 100644 --- a/src/components/FieldInput/index.js +++ b/src/components/FieldInput/index.js @@ -1,3 +1,5 @@ +/* Component to render input field */ + import React from 'react' import PropTypes from 'prop-types' import styles from './styles.module.scss' @@ -6,7 +8,8 @@ const FieldInput = ({ onChangeValue, placeholder, value, - type + type, + inputControl }) => { return ( ) } FieldInput.defaultProps = { type: 'text', - onChangeValue: () => {} + onChangeValue: () => {}, + inputControl: {} } FieldInput.propTypes = { onChangeValue: PropTypes.func, placeholder: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - type: PropTypes.string + type: PropTypes.string, + inputControl: PropTypes.any } export default FieldInput diff --git a/src/components/FieldLabelDynamic/index.js b/src/components/FieldLabelDynamic/index.js index 7e40b76b..03c0eb36 100644 --- a/src/components/FieldLabelDynamic/index.js +++ b/src/components/FieldLabelDynamic/index.js @@ -1,3 +1,5 @@ +/* Component to render field label */ + import React from 'react' import PropTypes from 'prop-types' import styles from './styles.module.scss' @@ -23,11 +25,13 @@ const FieldLabelDynamic = ({ className )} > -
- -
+ {!!title && ( +
+ +
+ )}
{info &&
{info}
} diff --git a/src/components/FieldUserAutoComplete/index.js b/src/components/FieldUserAutoComplete/index.js new file mode 100644 index 00000000..44fbcff0 --- /dev/null +++ b/src/components/FieldUserAutoComplete/index.js @@ -0,0 +1,61 @@ +/* Component to render select user field */ + +import React, { useMemo } from 'react' +import _ from 'lodash' +import PropTypes from 'prop-types' +import Select from '../Select' + +const FieldUserAutoComplete = ({ + value, + onChangeValue, + id, + projectMembers, + loggedInUser +}) => { + const selectedUsers = useMemo(() => { + return value.map(item => { + const selectedUser = _.find(projectMembers, { userId: item }) + return { + label: selectedUser.handle, + value: selectedUser.userId + } + }) + }, [value, projectMembers]) + + return ( +
)} + {isEdit && isProjectCancelled && ( + ( +
+
+ +
+
+ + {errors.projectStatus && ( +
+ {errors.projectStatus.message} +
+ )} +
+
+ )} + /> + )} {!isEdit && (
diff --git a/src/components/Select/index.js b/src/components/Select/index.js index 97a8ac36..b8cd5adf 100644 --- a/src/components/Select/index.js +++ b/src/components/Select/index.js @@ -1,13 +1,40 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import _ from 'lodash' -import ReactSelect from 'react-select' +import ReactSelect, { components } from 'react-select' import CreatableSelect from 'react-select/creatable' import AsyncSelect from 'react-select/async' import PT from 'prop-types' import styles from './styles' +const menuList = ({ onMenuScrollBottom }) => { + let menuListRef = null + + const handleOnScroll = (ev) => { + ev.preventDefault() + const el = ev.target + if (el.scrollTop + el.offsetHeight >= el.scrollHeight - 10) { + onMenuScrollBottom() + } + } + + const setMenuListRef = (ref) => { + if (!menuListRef) { + ref.addEventListener('scroll', handleOnScroll, false) + } + menuListRef = ref + } + return (props) => ( + { setMenuListRef(ref); props.innerRef(ref) } : props.innerRef} /> + ) +} + export default function Select (props) { const { selectRef, isCreatable, isAsync } = props + const [components, setComponents] = useState({}) + + useEffect(() => { + setComponents((prev) => ({ ...prev, MenuList: menuList(props) })) + }, [props.onMenuScrollBottom]) if (isAsync) { return ( ) } @@ -45,5 +73,6 @@ Select.defaultProps = { Select.propTypes = { selectRef: PT.func, isCreatable: PT.bool, - isAsync: PT.bool + isAsync: PT.bool, + onMenuScrollBottom: PT.func } diff --git a/src/components/Select/styles.js b/src/components/Select/styles.js index c4d323d4..a2b44a38 100644 --- a/src/components/Select/styles.js +++ b/src/components/Select/styles.js @@ -7,7 +7,6 @@ export default { let styles = { ...provided, borderRadius: '2px !important', - height: '40px', minHeight: '40px' } if (state.isFocused) { diff --git a/src/components/Table/Table.module.scss b/src/components/Table/Table.module.scss index e1b9807d..75448e86 100644 --- a/src/components/Table/Table.module.scss +++ b/src/components/Table/Table.module.scss @@ -1,40 +1,43 @@ -@import "../../styles/includes"; +@import '../../styles/includes'; table { border-spacing: 0; - tbody > tr { - display: flex; - flex-wrap: wrap; - background-color: $white; - font-size: 15px; - padding: 0 20px; - - td { - width: 100%; - } - td:last-of-type { - justify-content: flex-end; + tr { + border-bottom: 1px solid #d5d5d5; + } + + tbody { + tr { + &:hover { + background-color: rgba($tc-dark-blue-10, 0.5); + } } } - > thead > tr { - display: flex; - flex-wrap: wrap; - background-color: $light-bg; - font-size: 15px; - padding: 0 20px; - - th { - @include roboto(); - width: 100%; - text-align: start; - padding: 15px 0 15px 0; + th { + padding: 10px 10px; + color: grey; + font-size: 13px; + line-height: 30px; + } + + td { + padding: 15px 10px; + font-size: 14px; + line-height: 20px; + } + + th, + td { + @include roboto(); + + &:last-of-type { + padding-right: 20px; } - th:last-of-type { - text-align: right; - justify-content: flex-end; + &:first-of-type { + padding-left: 20px; } } } @@ -42,7 +45,3 @@ table { .hidden { display: none; } - -tbody.expand { - width: 100%; -} diff --git a/src/components/Table/index.js b/src/components/Table/index.js index 07c76ad7..e0a491a3 100644 --- a/src/components/Table/index.js +++ b/src/components/Table/index.js @@ -6,9 +6,9 @@ import PropTypes from 'prop-types' import cn from 'classnames' import styles from './Table.module.scss' -const Table = (props) => { +const Table = props => { const { options, rows, className } = props - const headers = options.map(o => {o.name}) + const headers = options.map(o => {o.name}) // If the table is expandable it uses multiple tbodys return ( @@ -16,7 +16,7 @@ const Table = (props) => { {headers} {props.expandable && rows} - {!props.expandable && ({rows})} + {!props.expandable && {rows}}
) } @@ -32,9 +32,11 @@ Table.propTypes = { className: PropTypes.string } -Table.Row = (props) => { +Table.Row = props => { return ( - {props.children} + + {props.children} + ) } @@ -44,10 +46,8 @@ Table.Row.propTypes = { onClick: PropTypes.func } -Table.Col = (props) => { - return ( - {props.children} - ) +Table.Col = props => { + return {props.children} } Table.Col.propTypes = { @@ -55,16 +55,22 @@ Table.Col.propTypes = { width: PropTypes.number } -Table.ExpandableRow = (props) => { +Table.ExpandableRow = props => { return ( - <> - - - - - {props.expandRows} - - + <> + + + + + {props.expandRows} + + ) } diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 21440a17..75728b99 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types' import styles from './Tooltip.module.scss' import { usePopper } from 'react-popper' -const Tooltip = ({ content, children }) => { +const Tooltip = ({ content, children, closeOnClick }) => { const [isOpen, setIsOpen] = useState(false) const [referenceElement, setReferenceElement] = useState(null) const [popperElement, setPopperElement] = useState(null) @@ -69,15 +69,27 @@ const Tooltip = ({ content, children }) => { setIsOpen(true) }, [setIsOpen]) + const defaultContentProps = { + onMouseEnter: open, + onMouseLeave: close, + innerRef: setReferenceElement, + ref: setReferenceElement + } + + const getContentElementProps = child => closeOnClick ? { + ...defaultContentProps, + onClick: (event) => { + if (typeof child.props.onClick === 'function') { + child.props.onClick(event) + } + close(event) + } + } : defaultContentProps + return ( <> {React.Children.map(children, (child) => - React.cloneElement(child, { - onMouseEnter: open, - onMouseLeave: close, - innerRef: setReferenceElement, - ref: setReferenceElement - }) + React.cloneElement(child, getContentElementProps(child)) )} {isOpen && ( @@ -101,7 +113,8 @@ const Tooltip = ({ content, children }) => { Tooltip.propTypes = { content: PropTypes.node, - children: PropTypes.node + children: PropTypes.node, + closeOnClick: PropTypes.bool } export default Tooltip diff --git a/src/components/UpdateBillingAccount/index.js b/src/components/UpdateBillingAccount/index.js index b20cd9b4..d45de764 100644 --- a/src/components/UpdateBillingAccount/index.js +++ b/src/components/UpdateBillingAccount/index.js @@ -17,7 +17,9 @@ const UpdateBillingAccount = ({ isAdmin, currentBillingAccount, projectId, - updateProject + updateProject, + isMemberOfActiveProject, + isManager }) => { const [isEditing, setIsEditing] = useState(false) const [selectedBillingAccount, setSelectedBillingAccount] = useState(null) @@ -129,7 +131,7 @@ const UpdateBillingAccount = ({ !currentBillingAccount && ( No Billing Account set - {isAdmin && ( + {(isAdmin || (isManager && isMemberOfActiveProject)) && ( {' '} ({' '} @@ -153,7 +155,7 @@ const UpdateBillingAccount = ({ > {isBillingAccountExpired ? 'INACTIVE' : 'ACTIVE'} {' '} - {isAdmin && ( + {(isAdmin || (isManager && isMemberOfActiveProject)) && ( {' '} ({' '} @@ -187,7 +189,9 @@ UpdateBillingAccount.propTypes = { isBillingAccountExpired: PropTypes.bool, isAdmin: PropTypes.bool, projectId: PropTypes.number, - updateProject: PropTypes.func.isRequired + updateProject: PropTypes.func.isRequired, + isMemberOfActiveProject: PropTypes.bool.isRequired, + isManager: PropTypes.bool.isRequired } export default UpdateBillingAccount diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js index 87387714..58b30ab7 100644 --- a/src/components/UserCard/index.js +++ b/src/components/UserCard/index.js @@ -1,3 +1,5 @@ +import _ from 'lodash' +import moment from 'moment' import React, { Component } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' @@ -6,7 +8,6 @@ import { PROJECT_ROLES } from '../../config/constants' import PrimaryButton from '../Buttons/PrimaryButton' import AlertModal from '../Modal/AlertModal' import { updateProjectMemberRole } from '../../services/projects' -import _ from 'lodash' const theme = { container: styles.modalContainer @@ -41,11 +42,11 @@ class UserCard extends Component { isUpdatingPermission: true }) - const { user, updateProjectNember } = this.props + const { user, updateProjectMember } = this.props try { const newUserInfoRole = await updateProjectMemberRole(user.projectId, user.id, newRole) - updateProjectNember(newUserInfoRole) + updateProjectMember(newUserInfoRole) this.setState({ showSuccessModal: true }) } catch (e) { const error = _.get( @@ -58,7 +59,7 @@ class UserCard extends Component { } render () { - const { user, onRemoveClick, isEditable } = this.props + const { isInvite, user, onRemoveClick, isEditable } = this.props const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) return (
@@ -90,76 +91,90 @@ class UserCard extends Component { )}
- {user.handle} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} - /> - -
)} + {isInvite ? (user.email || user.handle) : user.handle}
+ {!isInvite && ( + <> +
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} + /> + +
)} +
+ + )} + {isInvite && ( + <> +
+
+ Invited {moment(user.createdAt).format('MMM D, YY')} +
+
+
+ + )} {isEditable ? (
* { + width: 125px; + } } .addUserContentContainer { diff --git a/src/components/Users/index.js b/src/components/Users/index.js index 07d862dc..5ddc2e67 100644 --- a/src/components/Users/index.js +++ b/src/components/Users/index.js @@ -6,12 +6,14 @@ import styles from './Users.module.scss' import Select from '../Select' import UserCard from '../UserCard' import PrimaryButton from '../Buttons/PrimaryButton' -import Modal from '../Modal' -import SelectUserAutocomplete from '../SelectUserAutocomplete' import { PROJECT_ROLES, AUTOCOMPLETE_DEBOUNCE_TIME_MS } from '../../config/constants' -import { checkAdmin } from '../../util/tc' -import { addUserToProject, removeUserFromProject } from '../../services/projects' +import { checkAdmin, checkManager } from '../../util/tc' +import { removeUserFromProject } from '../../services/projects' +import { deleteProjectMemberInvite } from '../../services/projectMemberInvites' import ConfirmationModal from '../Modal/ConfirmationModal' +import UserAddModalContent from './user-add.modal' +import InviteUserModalContent from './invite-user.modal' // Import the new component +import Loader from '../Loader' const theme = { container: styles.modalContainer @@ -23,11 +25,7 @@ class Users extends Component { this.state = { projectOption: null, showAddUserModal: false, - userToAdd: null, - userPermissionToAdd: PROJECT_ROLES.READ, - showSelectUserError: false, - isAdding: false, - addUserError: false, + showInviteUserModal: false, // Add state for invite user modal isRemoving: false, removeError: null, showRemoveConfirmationModal: false, @@ -36,10 +34,9 @@ class Users extends Component { } this.setProjectOption = this.setProjectOption.bind(this) this.onAddUserClick = this.onAddUserClick.bind(this) + this.onInviteUserClick = this.onInviteUserClick.bind(this) // Bind the new method this.resetAddUserState = this.resetAddUserState.bind(this) - this.onUpdateUserToAdd = this.onUpdateUserToAdd.bind(this) - this.onAddUserConfirmClick = this.onAddUserConfirmClick.bind(this) - this.updatePermission = this.updatePermission.bind(this) + this.resetInviteUserState = this.resetInviteUserState.bind(this) // Bind reset method this.onRemoveClick = this.onRemoveClick.bind(this) this.resetRemoveUserState = this.resetRemoveUserState.bind(this) this.onRemoveConfirmClick = this.onRemoveConfirmClick.bind(this) @@ -54,78 +51,24 @@ class Users extends Component { loadProject(projectOption.value, false) } - updatePermission (newRole) { - this.setState({ - userPermissionToAdd: newRole - }) - } - onAddUserClick () { this.setState({ showAddUserModal: true }) } - resetAddUserState () { + onInviteUserClick () { this.setState({ - userToAdd: null, - showSelectUserError: false, - isAdding: false, - showAddUserModal: false, - userPermissionToAdd: PROJECT_ROLES.READ, - addUserError: null + showInviteUserModal: true }) } - onUpdateUserToAdd (option) { - let userToAdd = null - if (option && option.value) { - userToAdd = { - handle: option.label, - userId: parseInt(option.value, 10) - } - } - - this.setState({ - userToAdd, - showSelectUserError: !userToAdd - }) + resetAddUserState () { + this.setState({ showAddUserModal: false }) } - async onAddUserConfirmClick () { - const { addNewProjectMember } = this.props - if (this.state.isAdding) { return } - - this.setState({ - showSelectUserError: false, - addUserError: null - }) - - if (!this.state.userToAdd) { - this.setState({ - showSelectUserError: true - }) - return - } - - this.setState({ - isAdding: true - }) - - try { - const newUserInfo = await addUserToProject(this.state.projectOption.value, this.state.userToAdd.userId, this.state.userPermissionToAdd) - newUserInfo.handle = this.state.userToAdd.handle - // wait for a second so that project's members are updated - addNewProjectMember(newUserInfo) - this.resetAddUserState() - } catch (e) { - const error = _.get( - e, - 'response.data.message', - `Unable to add user` - ) - this.setState({ isAdding: false, addUserError: error }) - } + resetInviteUserState () { + this.setState({ showInviteUserModal: false }) } getHandle () { @@ -167,12 +110,15 @@ class Users extends Component { async onRemoveConfirmClick () { if (this.state.isRemoving) { return } - const { removeProjectNember } = this.props + const { removeProjectMember, invitedMembers } = this.props const userToRemove = this.state.userToRemove + const isInvite = !!_.find(invitedMembers, { email: userToRemove.email }) try { this.setState({ isRemoving: true }) - await removeUserFromProject(userToRemove.projectId, userToRemove.id) - removeProjectNember(userToRemove) + await ( + isInvite ? deleteProjectMemberInvite(userToRemove.projectId, userToRemove.id) : removeUserFromProject(userToRemove.projectId, userToRemove.id) + ) + removeProjectMember(userToRemove) this.resetRemoveUserState() } catch (e) { @@ -210,10 +156,13 @@ class Users extends Component { const { projects, projectMembers, - updateProjectNember, + invitedMembers, + updateProjectMember, isEditable, isSearchingUserProjects, - resultSearchUserProjects + resultSearchUserProjects, + loadNextProjects, + isLoadingProject } = this.props const { searchKey @@ -225,10 +174,11 @@ class Users extends Component { } }) const loggedInHandle = this.getHandle() - const membersExist = projectMembers && projectMembers.length > 0 + const membersExist = (projectMembers && projectMembers.length > 0) || (invitedMembers && invitedMembers.length > 0) const isCopilotOrManager = this.checkIsCopilotOrManager(projectMembers, loggedInHandle) const isAdmin = checkAdmin(this.props.auth.token) - const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin) + const isManager = checkManager(this.props.auth.token) + const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin || isManager) return (
@@ -246,6 +196,7 @@ class Users extends Component { onChange={(e) => { this.setProjectOption(e) }} onInputChange={this.debouncedOnInputChange} isLoading={isSearchingUserProjects} + onMenuScrollBottom={loadNextProjects} filterOption={() => true} noOptionsMessage={() => isSearchingUserProjects ? 'Searching...' : 'No options'} /> @@ -260,140 +211,39 @@ class Users extends Component { text={'Add User'} type={'info'} onClick={() => this.onAddUserClick()} /> + this.onInviteUserClick()} />
) } { this.state.showAddUserModal && ( - this.resetAddUserState()}> -
-
Add User
-
-
-
- Member* : -
-
- -
-
- { - this.state.showSelectUserError && ( -
-
Please select a member.
-
- ) - } -
-
- -
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} - /> - -
-
-
- { - this.state.addUserError && ( -
- {this.state.addUserError} -
- ) - } -
- -
-
- this.resetAddUserState()} - /> -
-
- this.onAddUserConfirmClick()} - /> -
-
-
-
+ + ) + } + { + this.state.showInviteUserModal && ( + ) } { this.state.showRemoveConfirmationModal && (
@@ -430,7 +280,23 @@ class Users extends Component { + + ) + }) + } + +
    + { + _.map(invitedMembers, (member) => { + return ( +
  • +
  • ) @@ -441,6 +307,8 @@ class Users extends Component { ) } + {isLoadingProject && } +
) } @@ -448,16 +316,20 @@ class Users extends Component { Users.propTypes = { loadProject: PropTypes.func.isRequired, - updateProjectNember: PropTypes.func.isRequired, - removeProjectNember: PropTypes.func.isRequired, + updateProjectMember: PropTypes.func.isRequired, + removeProjectMember: PropTypes.func.isRequired, + addNewProjectInvite: PropTypes.func.isRequired, addNewProjectMember: PropTypes.func.isRequired, auth: PropTypes.object, isEditable: PropTypes.bool, isSearchingUserProjects: PropTypes.bool, projects: PropTypes.arrayOf(PropTypes.object), projectMembers: PropTypes.arrayOf(PropTypes.object), + invitedMembers: PropTypes.arrayOf(PropTypes.object), + isLoadingProject: PropTypes.bool.isRequired, searchUserProjects: PropTypes.func.isRequired, - resultSearchUserProjects: PropTypes.arrayOf(PropTypes.object) + resultSearchUserProjects: PropTypes.arrayOf(PropTypes.object), + loadNextProjects: PropTypes.func.isRequired } export default Users diff --git a/src/components/Users/invite-user.modal.js b/src/components/Users/invite-user.modal.js new file mode 100644 index 00000000..37caa3b7 --- /dev/null +++ b/src/components/Users/invite-user.modal.js @@ -0,0 +1,141 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { find, get } from 'lodash' +import Modal from '../Modal' +import PrimaryButton from '../Buttons/PrimaryButton' +import { inviteUserToProject } from '../../services/projects' +import { PROJECT_ROLES } from '../../config/constants' + +import styles from './Users.module.scss' + +const theme = { + container: styles.modalContainer +} + +const validateEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +const InviteUserModalContent = ({ projectId, onClose, onMemberInvited, projectMembers, invitedMembers }) => { + const [emailToInvite, setEmailToInvite] = useState('') + const [showEmailError, setShowEmailError] = useState(false) + const [inviteUserError, setInviteUserError] = useState(null) + const [isInviting, setIsInviting] = useState(false) + + const checkEmail = () => { + if (!validateEmail(emailToInvite)) { + setShowEmailError(true) + return false + } + + if (find(invitedMembers, { email: emailToInvite })) { + setInviteUserError('Email is already invited!') + return false + } + + if (find(projectMembers, { email: emailToInvite })) { + setInviteUserError('Member already part of the project!') + return false + } + + return true + } + + const onInviteUserConfirmClick = async () => { + if (isInviting) return + + if (!checkEmail()) { + return + } + + setIsInviting(true) + setInviteUserError(null) + + try { + // api restriction: ONLY "customer" role can be invited via email + const { success: invitations = [], failed } = await inviteUserToProject(projectId, { + emails: [emailToInvite], + role: PROJECT_ROLES.CUSTOMER + }) + + if (failed) { + const error = get(failed, '0.message', 'Unable to invite user') + setInviteUserError(error) + setIsInviting(false) + } else { + onMemberInvited(invitations[0] || {}) + onClose() + } + } catch (e) { + const error = get(e, 'response.data.message', 'Unable to invite user') + setInviteUserError(error) + setIsInviting(false) + } + } + + return ( + +
+
Invite User
+
+
+
+ Email* : +
+
+ { + setEmailToInvite(e.target.value) + setShowEmailError(false) + setInviteUserError(null) + }} + onBlur={checkEmail} + /> +
+
+ {showEmailError && ( +
+
Please enter a valid email address.
+
+ )} + {inviteUserError && ( +
+
{inviteUserError}
+
+ )} +
+
+
+ +
+
+ +
+
+
+
+ ) +} + +InviteUserModalContent.propTypes = { + projectId: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + onMemberInvited: PropTypes.func.isRequired, + projectMembers: PropTypes.arrayOf(PropTypes.object), + invitedMembers: PropTypes.arrayOf(PropTypes.object) +} + +export default InviteUserModalContent diff --git a/src/components/Users/user-add.modal.js b/src/components/Users/user-add.modal.js new file mode 100644 index 00000000..58b68d20 --- /dev/null +++ b/src/components/Users/user-add.modal.js @@ -0,0 +1,194 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { get } from 'lodash' +import Modal from '../Modal' +import SelectUserAutocomplete from '../SelectUserAutocomplete' +import { PROJECT_ROLES } from '../../config/constants' +import PrimaryButton from '../Buttons/PrimaryButton' +import { addUserToProject, inviteUserToProject } from '../../services/projects' + +import styles from './Users.module.scss' + +const theme = { + container: styles.modalContainer +} + +const UserAddModalContent = ({ projectId, addNewProjectMember, onMemberInvited, onClose }) => { + const [userToAdd, setUserToAdd] = useState(null) + const [userPermissionToAdd, setUserPermissionToAdd] = useState(PROJECT_ROLES.READ) + const [showSelectUserError, setShowSelectUserError] = useState(false) + const [addUserError, setAddUserError] = useState(null) + const [isAdding, setIsAdding] = useState(false) + + const onUpdateUserToAdd = (option) => { + if (option && option.value) { + setUserToAdd({ + handle: option.label, + userId: parseInt(option.value, 10) + }) + setShowSelectUserError(false) + } else { + setUserToAdd(null) + } + } + + const onAddUserConfirmClick = async () => { + if (isAdding) return + + if (!userToAdd) { + setShowSelectUserError(true) + return + } + + setIsAdding(true) + setAddUserError(null) + + try { + if (userPermissionToAdd === PROJECT_ROLES.COPILOT) { + const { success: invitations = [], failed, ...rest } = await inviteUserToProject(projectId, { + handles: [userToAdd.handle], + role: userPermissionToAdd + }) + if (failed) { + const error = get(failed, '0.message', 'User cannot be invited') + setAddUserError(error) + setIsAdding(false) + } else if (rest.message) { + setAddUserError(rest.message) + setIsAdding(false) + } else { + onMemberInvited(invitations[0] || {}) + onClose() + } + } else { + const newUserInfo = await addUserToProject(projectId, userToAdd.userId, userPermissionToAdd) + newUserInfo.handle = userToAdd.handle + addNewProjectMember(newUserInfo) + onClose() + } + } catch (e) { + const error = get(e, 'response.data.message', 'Unable to add user') + setAddUserError(error) + setIsAdding(false) + } + } + + return ( + +
+
Add User
+
+
+
+ Member* : +
+
+ +
+
+ {showSelectUserError && ( +
+
Please select a member.
+
+ )} +
+
+ +
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.READ)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.WRITE)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.MANAGER)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.COPILOT)} + /> + +
+
+
+ {addUserError && ( +
{addUserError}
+ )} +
+
+
+ +
+
+ +
+
+
+
+ ) +} +UserAddModalContent.propTypes = { + projectId: PropTypes.number.isRequired, + addNewProjectMember: PropTypes.func.isRequired, + onMemberInvited: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +} + +export default UserAddModalContent diff --git a/src/config/constants.js b/src/config/constants.js index 2ee6e0ee..0b7de032 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -4,6 +4,7 @@ export const { COMMUNITY_APP_URL, CHALLENGE_API_URL, + COPILOTS_URL, SUBMISSION_REVIEW_APP_URL, STUDIO_URL, CONNECT_APP_URL, @@ -30,9 +31,11 @@ export const { SKILLS_V5_API_URL, UPDATE_SKILLS_V5_API_URL, SALESFORCE_BILLING_ACCOUNT_LINK, - TYPEFORM_URL + PROFILE_URL } = process.env + export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS === 'string' ? process.env.CREATE_FORUM_TYPE_IDS.split(',') : process.env.CREATE_FORUM_TYPE_IDS +export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL /** * Filepicker config @@ -41,9 +44,14 @@ export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS == // but if we want to test file uploading we should provide the real value in `FILE_PICKER_API_KEY` env variable export const FILE_PICKER_API_KEY = process.env.FILE_PICKER_API_KEY || 'DUMMY' export const FILE_PICKER_CONTAINER_NAME = process.env.FILE_PICKER_CONTAINER_NAME || 'tc-challenge-v5-dev' +export const FILE_PICKER_SUBMISSION_CONTAINER_NAME = process.env.FILE_PICKER_SUBMISSION_CONTAINER_NAME || 'submission-staging-dev' +export const PROJECT_ATTACHMENTS_FOLDER = process.env.PROJECT_ATTACHMENTS_FOLDER || 'PROJECT_ATTACHMENTS' export const FILE_PICKER_REGION = process.env.FILE_PICKER_REGION || 'us-east-1' +export const FILE_PICKER_LOCATION = process.env.FILE_PICKER_LOCATION || 's3' export const FILE_PICKER_CNAME = process.env.FILE_PICKER_CNAME || 'fs.topcoder.com' export const FILE_PICKER_FROM_SOURCES = ['local_file_system', 'googledrive', 'dropbox'] +export const ASSETS_FILE_PICKER_FROM_SOURCES = ['local_file_system'] +export const ASSETS_FILE_PICKER_MAX_FILES = 4 export const FILE_PICKER_ACCEPT = ['.bmp', '.gif', '.jpg', '.tex', '.xls', '.xlsx', '.doc', '.docx', '.zip', '.txt', '.pdf', '.png', '.ppt', '.pptx', '.rtf', '.csv'] export const FILE_PICKER_MAX_FILES = 10 export const FILE_PICKER_MAX_SIZE = 500 * 1024 * 1024 // 500Mb @@ -51,6 +59,7 @@ export const FILE_PICKER_PROGRESS_INTERVAL = 100 export const FILE_PICKER_UPLOAD_RETRY = 2 export const FILE_PICKER_UPLOAD_TIMEOUT = 30 * 60 * 1000 // 30 minutes export const SPECIFICATION_ATTACHMENTS_FOLDER = 'SPECIFICATION_ATTACHMENTS' +export const MEMBERS_API_URL = process.env.MEMBERS_API_URL export const getAWSContainerFileURL = (key) => `https://${FILE_PICKER_CONTAINER_NAME}.s3.amazonaws.com/${key}` @@ -173,16 +182,33 @@ export const LOAD_PROJECT_TYPES_SUCCESS = 'LOAD_PROJECT_TYPES_SUCCESS' export const LOAD_PROJECT_TYPES_PENDING = 'LOAD_PROJECT_TYPES_PENDING' export const LOAD_PROJECT_TYPES_FAILURE = 'LOAD_PROJECT_TYPES_FAILURE' +export const LOAD_PROJECT_INVITES = 'LOAD_PROJECT_INVITES' +export const LOAD_PROJECT_INVITES_SUCCESS = 'LOAD_PROJECT_INVITES_SUCCESS' +export const LOAD_PROJECT_INVITES_PENDING = 'LOAD_PROJECT_INVITES_PENDING' +export const LOAD_PROJECT_INVITES_FAILURE = 'LOAD_PROJECT_INVITES_FAILURE' + export const CREATE_PROJECT = 'CREATE_PROJECT' export const CREATE_PROJECT_PENDING = 'CREATE_PROJECT_PENDING' export const CREATE_PROJECT_SUCCESS = 'CREATE_PROJECT_SUCCESS' export const CREATE_PROJECT_FAILURE = 'CREATE_PROJECT_FAILURE' +export const ADD_PROJECT_ATTACHMENT_SUCCESS = 'ADD_PROJECT_ATTACHMENT_SUCCESS' +export const UPDATE_PROJECT_ATTACHMENT_SUCCESS = 'UPDATE_PROJECT_ATTACHMENT_SUCCESS' +export const REMOVE_PROJECT_ATTACHMENT_SUCCESS = 'REMOVE_PROJECT_ATTACHMENT_SUCCESS' + export const UPDATE_PROJECT = 'UPDATE_PROJECT' export const UPDATE_PROJECT_PENDING = 'UPDATE_PROJECT_PENDING' export const UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS' export const UPDATE_PROJECT_FAILURE = 'UPDATE_PROJECT_FAILURE' +export const PROJECT_MEMBER_INVITE_STATUS_ACCEPTED = 'accepted' +export const PROJECT_MEMBER_INVITE_STATUS_REFUSED = 'refused' +export const PROJECT_MEMBER_INVITE_STATUS_CANCELED = 'canceled' +export const PROJECT_MEMBER_INVITE_STATUS_PENDING = 'pending' +export const PROJECT_MEMBER_INVITE_STATUS_REQUESTED = 'requested' +export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_APPROVED = 'request_approved' +export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_REJECTED = 'request_rejected' + // Name of challenge tracks export const CHALLENGE_TRACKS = { DESIGN: DES_TRACK_ID, @@ -232,6 +258,7 @@ export const MARATHON_MATCH_SUBTRACKS = [ export const PROJECT_ROLES = { READ: 'observer', + CUSTOMER: 'customer', WRITE: 'customer', MANAGER: 'manager', COPILOT: 'copilot' @@ -298,11 +325,17 @@ export const COPILOT_ROLES = [ 'copilot' ] +export const MANAGER_ROLES = [ + 'project manager' +] + export const downloadAttachmentURL = (challengeId, attachmentId, token) => `${CHALLENGE_API_URL}/${challengeId}/attachments/${attachmentId}/download?token=${token}` export const PAGE_SIZE = 10 +export const PROJECTS_PAGE_SIZE = 20 + /** * The minimal number of characters to enter before starting showing autocomplete suggestions */ @@ -351,6 +384,13 @@ export const CANCEL_REASONS = [ 'Cancelled - Zero Registrations' ] +/** + * System reviewers + */ +export const SystemReviewers = { + Default: 'TC System' +} + /** * Milestone product details */ @@ -396,7 +436,8 @@ export const SPECIAL_CHALLENGE_TAGS = [ /** * Possible statuses of projects */ -export const PROJECT_STATUS = [ +export const PROJECT_STATUSES = [ + { label: 'Draft', value: 'draft' }, { label: 'Active', value: 'active' }, { label: 'In Review', value: 'in_review' }, { label: 'Reviewed', value: 'reviewed' }, @@ -405,6 +446,15 @@ export const PROJECT_STATUS = [ { label: 'Paused', value: 'paused' } ] +export const PROJECT_STATUS = { + ACTIVE: 'active', + IN_REVIEW: 'in_review', + REVIEWED: 'reviewed', + COMPLETED: 'completed', + CANCELLED: 'cancelled', + PAUSED: 'paused' +} + export const JOB_ROLE_OPTIONS = [ { value: null, label: 'Select Role' }, { value: 'designer', label: 'Designer' }, @@ -418,3 +468,17 @@ export const JOB_WORKLOAD_OPTIONS = [ { value: 'fulltime', label: 'Full-Time' }, { value: 'fractional', label: 'Fractional' } ] + +/* +* Project Attachment types +*/ +export const ATTACHMENT_TYPE_FILE = 'file' +export const ATTACHMENT_TYPE_LINK = 'link' + +/** + * Project assets shared with type text + */ +export const PROJECT_ASSETS_SHARED_WITH_ALL_MEMBERS = 'All Project Members' +export const PROJECT_ASSETS_SHARED_WITH_ADMIN = 'Only Admins' + +export const PROJECT_TYPE_TAAS = 'talent-as-a-service' diff --git a/src/containers/ChallengeEditor/index.js b/src/containers/ChallengeEditor/index.js index 2f55d3b5..dffcc934 100644 --- a/src/containers/ChallengeEditor/index.js +++ b/src/containers/ChallengeEditor/index.js @@ -62,7 +62,8 @@ class ChallengeEditor extends Component { showLaunchModal: false, showRejectModal: false, cancelReason: null, - loginUserRoleInProject: '' + loginUserRoleInProject: '', + submissionsListPage: 1 } this.onLaunchChallenge = this.onLaunchChallenge.bind(this) @@ -95,7 +96,8 @@ class ChallengeEditor extends Component { loadResourceRoles, loadSubmissions, loadChallengeDetails, - loadResources + loadResources, + submissionsPerPage } = this.props loadTimelineTemplates() loadChallengePhases() @@ -109,7 +111,8 @@ class ChallengeEditor extends Component { match, loadChallengeDetails, loadResources, - loadSubmissions + loadSubmissions, + submissionsPerPage ) // this.unlisten = this.props.history.listen(() => { // const { isLoading } = this.props @@ -160,12 +163,16 @@ class ChallengeEditor extends Component { newMatch, loadChallengeDetails, loadResources, - loadSubmissions + loadSubmissions, + submissionsPerPage ) { let projectId = _.get(newMatch.params, 'projectId', null) projectId = projectId ? parseInt(projectId) : null const challengeId = _.get(newMatch.params, 'challengeId', null) - await [loadResources(challengeId), loadSubmissions(challengeId)] + await [loadResources(challengeId), loadSubmissions(challengeId, { + page: 1, + perPage: submissionsPerPage + })] loadChallengeDetails(projectId, challengeId) if (!challengeId) { @@ -423,7 +430,11 @@ class ChallengeEditor extends Component { isProjectPhasesLoading, showRejectChallengeModal, createResource, - deleteResource + deleteResource, + totalSubmissions, + submissionsPerPage, + page, + loadSubmissions // members } = this.props const { @@ -623,6 +634,10 @@ class ChallengeEditor extends Component { onApproveChallenge={this.onApproveChallenge} createResource={createResource} deleteResource={deleteResource} + loadSubmissions={loadSubmissions} + totalSubmissions={totalSubmissions} + submissionsPerPage={submissionsPerPage} + page={page} /> )} /> @@ -680,13 +695,16 @@ ChallengeEditor.propTypes = { loadProject: PropTypes.func, projectPhases: PropTypes.arrayOf(PropTypes.object), isProjectPhasesLoading: PropTypes.bool, - showRejectChallengeModal: PropTypes.func + showRejectChallengeModal: PropTypes.func, + totalSubmissions: PropTypes.number, + submissionsPerPage: PropTypes.number, + page: PropTypes.number // members: PropTypes.arrayOf(PropTypes.shape()) } const mapStateToProps = ({ projects, - challengeSubmissions: { challengeSubmissions }, + challengeSubmissions: { challengeSubmissions, totalSubmissions, submissionsPerPage, page }, challenges: { challengeDetails, challengeResources, @@ -715,7 +733,10 @@ const mapStateToProps = ({ token: auth.token, loggedInUser: auth.user, failedToLoad, - errorMessage + errorMessage, + totalSubmissions, + submissionsPerPage, + page // members }) } diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index 4b56402a..f3edb55a 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -6,9 +6,7 @@ import React, { Component, Fragment } from 'react' // import { Redirect } from 'react-router-dom' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Link } from 'react-router-dom' import ChallengesComponent from '../../components/ChallengesComponent' -import ProjectCard from '../../components/ProjectCard' // import Loader from '../../components/Loader' import { loadChallengesByPage, @@ -16,15 +14,14 @@ import { deleteChallenge, loadChallengeTypes } from '../../actions/challenges' -import { loadProject, updateProject } from '../../actions/projects' +import { loadProject, loadProjects, updateProject } from '../../actions/projects' import { - loadProjects, + loadNextProjects, setActiveProject, resetSidebarActiveParams } from '../../actions/sidebar' -import styles from './Challenges.module.scss' -import { checkAdmin, checkAdminOrCopilot } from '../../util/tc' -import { PrimaryButton } from '../../components/Buttons' +import { checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' +import { withRouter } from 'react-router-dom' class Challenges extends Component { constructor (props) { @@ -46,6 +43,7 @@ class Challenges extends Component { } = this.props loadChallengeTypes() if (dashboard) { + this.props.loadProjects('', {}) this.reloadChallenges(this.props, true, true) } if (menu === 'NULL' && activeProjectId !== -1) { @@ -59,6 +57,14 @@ class Challenges extends Component { } } + componentDidUpdate () { + const { auth } = this.props + + if (checkIsUserInvitedToProject(auth.token, this.props.projectDetail)) { + this.props.history.push(`/projects/${this.props.projectId}/invitation`) + } + } + componentWillReceiveProps (nextProps) { if ( (nextProps.dashboard && this.props.dashboard !== nextProps.dashboard) || @@ -140,49 +146,20 @@ class Challenges extends Component { dashboard, selfService, auth, - metadata + metadata, + fetchNextProjects } = this.props const { challengeTypes = [] } = metadata - const projectInfo = _.find(projects, { id: activeProjectId }) || {} - const projectComponents = - !dashboard && - projects.map((p) => ( -
  • - -
  • - )) return ( - {!dashboard && - (!!projectComponents.length || - (activeProjectId === -1 && !selfService)) ? ( -
    - {activeProjectId === -1 && !selfService && ( -
    -
    No project selected. Select one below
    - {checkAdminOrCopilot(auth.token) && ( - - - - )} -
    - )} -
      {projectComponents}
    -
    - ) : null} {(dashboard || activeProjectId !== -1 || selfService) && ( ({ @@ -292,12 +272,15 @@ const mapDispatchToProps = { loadChallengesByPage, resetSidebarActiveParams, loadProject, - loadProjects, + fetchNextProjects: loadNextProjects, updateProject, loadChallengeTypes, setActiveProject, partiallyUpdateChallengeDetails, - deleteChallenge + deleteChallenge, + loadProjects } -export default connect(mapStateToProps, mapDispatchToProps)(Challenges) +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(Challenges) +) diff --git a/src/containers/ProjectAssets/index.jsx b/src/containers/ProjectAssets/index.jsx new file mode 100644 index 00000000..002ce66b --- /dev/null +++ b/src/containers/ProjectAssets/index.jsx @@ -0,0 +1,353 @@ +/* Component to render project assets page */ + +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import _ from 'lodash' +import * as filepicker from 'filestack-js' +import { toastr } from 'react-redux-toastr' +import PrimaryButton from '../../components/Buttons/PrimaryButton' +import OutlineButton from '../../components/Buttons/OutlineButton' +import TabCommon from '../../components/AssetsLibrary/TabCommon' +import TableAssets from '../../components/AssetsLibrary/TableAssets' +import ModalAttachmentOptions from '../../components/AssetsLibrary/ModalAttachmentOptions' +import ModalAddLink from '../../components/AssetsLibrary/ModalAddLink' +import ConfirmationModal from '../../components/Modal/ConfirmationModal' +import { loadOnlyProjectInfo, removeAttachment } from '../../actions/projects' + +import styles from './styles.module.scss' +import Loader from '../../components/Loader' +import { + ASSETS_FILE_PICKER_FROM_SOURCES, + ASSETS_FILE_PICKER_MAX_FILES, + ATTACHMENT_TYPE_FILE, + ATTACHMENT_TYPE_LINK, + FILE_PICKER_ACCEPT, + FILE_PICKER_API_KEY, + FILE_PICKER_CNAME, + FILE_PICKER_LOCATION, + FILE_PICKER_REGION, + FILE_PICKER_SUBMISSION_CONTAINER_NAME, + PROJECT_ATTACHMENTS_FOLDER +} from '../../config/constants' +import { removeProjectAttachmentApi } from '../../services/projects' +import { checkAdmin } from '../../util/tc' + +const theme = { + container: styles.modalContainer +} + +const ProjectAssets = ({ + projectId, + projectDetail, + loadOnlyProjectInfo, + isLoading, + removeAttachment, + loggedInUser, + token +}) => { + const [isProcessing, setIsProcessing] = useState(false) + const [selectedTab, setSelectedTab] = useState(0) + const [showDeleteFile, setShowDeleteFile] = useState(null) + const [showDeleteLink, setShowDeleteLink] = useState(null) + const [showAttachmentOptions, setShowAttachmentOptions] = useState(false) + const [pendingUploadFiles, setPendingUploadFiles] = useState([]) + const uploadedFiles = useRef([]) + const [showAddLink, setShowAddLink] = useState(false) + const hasProjectAccess = useMemo( + () => (projectDetail ? `${projectDetail.id}` === projectId : false), + [projectDetail, projectId] + ) + const isAdmin = useMemo(() => checkAdmin(token), [token]) + const fileUploadClient = useMemo(() => { + return filepicker.init(FILE_PICKER_API_KEY, { + cname: FILE_PICKER_CNAME + }) + }, []) + + const openFileUpload = useCallback(() => { + if (fileUploadClient && projectId) { + const attachmentsStorePath = `${PROJECT_ATTACHMENTS_FOLDER}/${projectId}/` + const picker = fileUploadClient.picker({ + storeTo: { + location: FILE_PICKER_LOCATION, + path: attachmentsStorePath, + container: FILE_PICKER_SUBMISSION_CONTAINER_NAME, + region: FILE_PICKER_REGION + }, + maxFiles: ASSETS_FILE_PICKER_MAX_FILES, + fromSources: ASSETS_FILE_PICKER_FROM_SOURCES, + accept: FILE_PICKER_ACCEPT, + uploadInBackground: false, + onFileUploadFinished: files => { + const attachments = [] + const fpFiles = _.isArray(files) ? files : [files] + _.forEach(fpFiles, f => { + const attachment = { + title: f.filename, + description: '', + size: f.size, + path: f.key, + type: ATTACHMENT_TYPE_FILE, + contentType: f.mimetype || 'application/unknown' + } + attachments.push(attachment) + }) + uploadedFiles.current = [...uploadedFiles.current, ...attachments] + }, + onOpen: () => { + uploadedFiles.current = [] + }, + onClose: () => { + if (uploadedFiles.current.length) { + setPendingUploadFiles([...uploadedFiles.current]) + setShowAttachmentOptions(true) + } + } + }) + + picker.open() + } + }, [fileUploadClient, projectId]) + + const files = useMemo(() => { + if (!hasProjectAccess) { + return [] + } + + let results = _.filter( + projectDetail.attachments, + a => a.type === ATTACHMENT_TYPE_FILE + ) + results = _.sortBy(results, file => -new Date(file.updatedAt).getTime()) + return results + }, [projectDetail, hasProjectAccess]) + + const links = useMemo(() => { + if (!hasProjectAccess) { + return [] + } + + let results = _.filter( + projectDetail.attachments, + a => a.type === ATTACHMENT_TYPE_LINK + ) + results = _.sortBy(results, file => -new Date(file.updatedAt).getTime()) + return results + }, [projectDetail, hasProjectAccess]) + + const { tableTitle, tableDatas, isLink } = useMemo(() => { + if (selectedTab === 0) { + return { + tableTitle: 'All Files', + tableDatas: files, + isLink: false + } + } else if (selectedTab === 1) { + return { + tableTitle: 'All Links', + tableDatas: links, + isLink: true + } + } + return { + tableTitle: '', + tableDatas: [], + isLink: false + } + }, [files, links, selectedTab]) + + useEffect(() => { + if (projectId) { + loadOnlyProjectInfo(projectId) + } + }, [projectId]) + + if (isLoading) { + return + } + + return ( +
    +
    +
    Assets Library
    +
    + {hasProjectAccess && ( +
    + { + if (selectedTab === 0) { + openFileUpload() + } else if (selectedTab === 1) { + setShowAddLink(true) + } + }} + /> +
    + )} +
    + +
    +
    +
    + {hasProjectAccess && ( + <> + + { + if (selectedTab === 0) { + setShowAttachmentOptions(item) + } else if (selectedTab === 1) { + setShowAddLink(item) + } + }} + onRemove={item => { + if (selectedTab === 0) { + setShowDeleteFile(item) + } else if (selectedTab === 1) { + setShowDeleteLink(item) + } + }} + datas={tableDatas} + isLink={isLink} + members={projectDetail.members} + loggedInUser={loggedInUser} + isAdmin={isAdmin} + /> + + )} + + {showAttachmentOptions && ( + setShowAttachmentOptions(false)} + attachment={ + showAttachmentOptions === true ? null : showAttachmentOptions + } + members={projectDetail.members} + projectId={projectId} + loggedInUser={loggedInUser} + newAttachments={pendingUploadFiles} + /> + )} + {showAddLink && ( + setShowAddLink(false)} + projectId={projectId} + link={showAddLink === true ? null : showAddLink} + /> + )} + {showDeleteFile && ( + setShowDeleteFile(null)} + theme={theme} + confirmText='Delete file' + confirmType='danger' + cancelType='info' + isProcessing={isProcessing} + onConfirm={() => { + setIsProcessing(true) + removeProjectAttachmentApi(projectId, showDeleteFile.id) + .then(() => { + toastr.success('Success', 'Removed file successfully.') + removeAttachment(showDeleteFile.id) + setIsProcessing(false) + setShowDeleteFile(null) + }) + .catch(e => { + setIsProcessing(false) + const errorMessage = _.get( + e, + 'response.data.message', + 'Failed to remove file.' + ) + toastr.error('Error', errorMessage) + }) + }} + /> + )} + {showDeleteLink && ( + setShowDeleteLink(null)} + onConfirm={() => { + setIsProcessing(true) + removeProjectAttachmentApi(projectId, showDeleteLink.id) + .then(() => { + toastr.success('Success', 'Removed link successfully.') + removeAttachment(showDeleteLink.id) + setIsProcessing(false) + setShowDeleteLink(null) + }) + .catch(e => { + setIsProcessing(false) + const errorMessage = _.get( + e, + 'response.data.message', + 'Failed to remove link.' + ) + toastr.error('Error', errorMessage) + }) + }} + theme={theme} + confirmText='Delete link' + confirmType='danger' + cancelType='info' + isProcessing={isProcessing} + /> + )} +
    + ) +} + +ProjectAssets.propTypes = { + projectId: PropTypes.string.isRequired, + token: PropTypes.string, + loadOnlyProjectInfo: PropTypes.func.isRequired, + removeAttachment: PropTypes.func.isRequired, + projectDetail: PropTypes.object, + isLoading: PropTypes.bool, + loggedInUser: PropTypes.object +} + +const mapStateToProps = ({ projects, auth }) => { + return { + projectDetail: projects.projectDetail, + isLoading: projects.isLoading, + loggedInUser: auth.user, + token: auth.token + } +} + +const mapDispatchToProps = { + loadOnlyProjectInfo, + removeAttachment +} + +export default connect(mapStateToProps, mapDispatchToProps)(ProjectAssets) diff --git a/src/containers/ProjectAssets/styles.module.scss b/src/containers/ProjectAssets/styles.module.scss new file mode 100644 index 00000000..6acf18b7 --- /dev/null +++ b/src/containers/ProjectAssets/styles.module.scss @@ -0,0 +1,65 @@ +@import '../../styles/includes'; + +.container { + padding: 30px 20px; +} + +.btn { + height: 40px; + + span, + button { + padding: 0 20px; + white-space: nowrap; + } +} + +.blockHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.title { + font-size: 24px; + font-weight: 700; + line-height: 29px; + color: $challenges-title; +} + +.modalContainer { + padding: 0; + position: fixed; + overflow: auto; + z-index: 10000; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + width: auto; + max-width: none; + transform: none; + background: transparent; + color: $text-color; + opacity: 1; + display: flex; + justify-content: center; + align-items: center; + + @include xs-to-lg { + max-width: 100vw; + } +} + +.btns { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.blockTabs { + margin-top: 20px; + margin-bottom: 30px; +} diff --git a/src/containers/ProjectEditor/ProjectEditor.module.scss b/src/containers/ProjectEditor/ProjectEditor.module.scss index aa73035e..1a9587c2 100644 --- a/src/containers/ProjectEditor/ProjectEditor.module.scss +++ b/src/containers/ProjectEditor/ProjectEditor.module.scss @@ -37,6 +37,9 @@ .actionButtons { top: 30px; + display: flex; + gap: 20px; + a,button { height: 40px; } @@ -251,3 +254,8 @@ } } } + +.btnOutline { + padding: 0 40px; + white-space: nowrap; +} \ No newline at end of file diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 2fc23e25..2627e99e 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -15,8 +15,10 @@ import { updateProject } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdminOrCopilot } from '../../util/tc' +import { checkAdminOrCopilot, checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' +import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' + class ProjectEditor extends Component { constructor (props) { super(props) @@ -35,7 +37,12 @@ class ProjectEditor extends Component { componentDidUpdate () { const { auth } = this.props - if (!checkAdminOrCopilot(auth.token)) { + + if (checkIsUserInvitedToProject(auth.token, this.props.projectDetail)) { + this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) + } + + if (!checkAdminOrCopilot(auth.token, this.props.projectDetail)) { this.props.history.push('/projects') } } @@ -53,6 +60,25 @@ class ProjectEditor extends Component { } } + getMemberRole (members, userId) { + if (!userId) { return null } + + const found = _.find(members, (m) => { + return m.userId === userId + }) + + return _.get(found, 'role') + } + + checkIsCopilotOrManager (projectMembers, userId) { + if (projectMembers && projectMembers.length > 0) { + const role = this.getMemberRole(projectMembers, userId) + return role === PROJECT_ROLES.COPILOT || role === PROJECT_ROLES.MANAGER + } else { + return false + } + } + render () { const { match, @@ -66,8 +92,13 @@ class ProjectEditor extends Component { isProjectLoading, projectDetail } = this.props + if (isProjectTypesLoading || (isEdit && isProjectLoading)) return + const isAdmin = checkAdmin(this.props.auth.token) + const isCopilotOrManager = this.checkIsCopilotOrManager(_.get(projectDetail, 'members', []), _.get(this.props.auth, 'user.userId', null)) + const canManage = isAdmin || isCopilotOrManager + const projectId = this.getProjectId(match) return (
    @@ -97,6 +128,7 @@ class ProjectEditor extends Component { setActiveProject={setActiveProject} history={history} isEdit={isEdit} + canManage={canManage} projectDetail={projectDetail} />
    diff --git a/src/containers/ProjectInvitations/ProjectInvitations.module.scss b/src/containers/ProjectInvitations/ProjectInvitations.module.scss new file mode 100644 index 00000000..865f95f9 --- /dev/null +++ b/src/containers/ProjectInvitations/ProjectInvitations.module.scss @@ -0,0 +1,120 @@ +@import '../../styles/includes'; + +.modalContainer { + padding: 0; + position: fixed; + overflow: auto; + z-index: 10000; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + width: auto; + max-width: none; + transform: none; + background: transparent; + color: $text-color; + opacity: 1; + display: flex; + justify-content: center; + align-items: center; + + :global { + button.close { + margin-right: 5px; + margin-top: 5px; + } + } + + .contentContainer { + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + border-radius: 6px; + margin: 0 auto; + width: 852px; + padding: 30px; + + .content { + padding: 30px; + width: 100%; + height: 100%; + } + + .title { + @include roboto-bold(); + + font-size: 30px; + line-height: 36px; + margin-bottom: 30px; + margin-top: 0; + } + + span { + @include roboto; + + font-size: 22px; + font-weight: 400; + line-height: 26px; + } + + &.confirm { + width: 999px; + + .buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 30px; + + .buttonSizeA { + width: 193px; + height: 40px; + margin-right: 33px; + + span { + font-size: 18px; + font-weight: 500; + } + } + + .buttonSizeB { + width: 160px; + height: 40px; + + span { + font-size: 18px; + font-weight: 500; + line-height: 22px; + } + } + } + } + + .buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 30px; + + .button { + width: 135px; + height: 40px; + margin-right: 66px; + + span { + font-size: 18px; + font-weight: 500; + } + } + + .button:last-child { + margin-right: 0; + } + } + } +} \ No newline at end of file diff --git a/src/containers/ProjectInvitations/index.js b/src/containers/ProjectInvitations/index.js new file mode 100644 index 00000000..58e6f20d --- /dev/null +++ b/src/containers/ProjectInvitations/index.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { toastr } from 'react-redux-toastr' +import { checkIsUserInvitedToProject } from '../../util/tc' +import { isEmpty } from 'lodash' +import { loadProjectInvites } from '../../actions/projects' +import ConfirmationModal from '../../components/Modal/ConfirmationModal' + +import styles from './ProjectInvitations.module.scss' +import { updateProjectMemberInvite } from '../../services/projectMemberInvites' +import { PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED } from '../../config/constants' +import { delay } from '../../util/delay' + +const theme = { + container: styles.modalContainer +} + +const ProjectInvitations = ({ match, auth, isProjectLoading, history, projectDetail, loadProjectInvites }) => { + const automaticAction = useMemo(() => [PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED].includes(match.params.action) ? match.params.action : undefined, [match.params]) + const projectId = useMemo(() => parseInt(match.params.projectId), [match.params]) + const invitation = useMemo(() => checkIsUserInvitedToProject(auth.token, projectDetail), [auth.token, projectDetail]) + const [isUpdating, setIsUpdating] = useState(automaticAction || false) + const isAccepting = isUpdating === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED + const isDeclining = isUpdating === PROJECT_MEMBER_INVITE_STATUS_REFUSED + const queryParams = new URLSearchParams(window.location.search) + const source = queryParams.get('source') + + useEffect(() => { + if (!projectId) { + return + } + + if (isProjectLoading || isEmpty(projectDetail)) { + if (!isProjectLoading) { + loadProjectInvites(projectId) + } + return + } + + if (!invitation) { + history.push(`/projects`) + } + }, [projectId, auth, projectDetail, isProjectLoading, history]) + + const updateInvite = useCallback(async (status, source) => { + setIsUpdating(status) + await updateProjectMemberInvite(projectId, invitation.id, status, source) + + // await for the project details to propagate + await delay(1000) + await loadProjectInvites(projectId) + toastr.success('Success', `Successfully ${status} the invitation.`) + + // await for the project details to fetch + await delay(1000) + history.push(status === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED ? `/projects/${projectId}/challenges` : '/projects') + }, [projectId, invitation, loadProjectInvites, history]) + + const acceptInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, source), [updateInvite, source]) + const declineInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_REFUSED, source), [updateInvite, source]) + + useEffect(() => { + if (!invitation || !automaticAction) { + return + } + + if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED) { + acceptInvite() + } else if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_REFUSED) { + declineInvite() + } + }, [invitation, automaticAction, source]) + + return ( + <> + {invitation && ( + + )} + + ) +} + +ProjectInvitations.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + projectId: PropTypes.string + }) + }).isRequired, + auth: PropTypes.object.isRequired, + isProjectLoading: PropTypes.bool, + history: PropTypes.object, + loadProjectInvites: PropTypes.func.isRequired, + projectDetail: PropTypes.object +} + +const mapStateToProps = ({ projects, auth }) => { + return { + projectDetail: projects.projectDetail, + isProjectLoading: projects.isLoading || projects.isProjectInvitationsLoading, + auth + } +} + +const mapDispatchToProps = { + loadProjectInvites +} + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(ProjectInvitations) +) diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js new file mode 100644 index 00000000..893f3f78 --- /dev/null +++ b/src/containers/Projects/index.js @@ -0,0 +1,162 @@ +import React, { useEffect, useMemo, useState } from 'react' +import cn from 'classnames' +import { DebounceInput } from 'react-debounce-input' +import { withRouter, Link } from 'react-router-dom' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import Loader from '../../components/Loader' +import { checkAdminOrCopilot, checkIsUserInvitedToProject, checkManager } from '../../util/tc' +import { PrimaryButton } from '../../components/Buttons' +import Select from '../../components/Select' +import ProjectCard from '../../components/ProjectCard' +import InfiniteLoadTrigger from '../../components/InfiniteLoadTrigger' +import { loadProjects, loadMoreProjects, unloadProjects } from '../../actions/projects' +import { PROJECT_STATUSES } from '../../config/constants' + +import styles from './styles.module.scss' + +const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, loadMoreProjects, unloadProjects }) => { + const [search, setSearch] = useState() + const [projectStatus, setProjectStatus] = useState('') + const [showOnlyMyProjects, setOnlyMyProjects] = useState(false) + const selectedStatus = useMemo(() => PROJECT_STATUSES.find(s => s.value === projectStatus)) + + const isProjectManager = checkManager(auth.token) + useEffect(() => { + const params = {} + if (projectStatus) { + params.status = projectStatus + } + + if (isProjectManager) { + params.memberOnly = showOnlyMyProjects + } + loadProjects(search, params) + }, [search, projectStatus, showOnlyMyProjects, isProjectManager]) + + // unload projects on dismount + useEffect(() => () => unloadProjects, []) + + if (isLoading && projects.length === 0) { + return ( +
    + +
    + ) + } + + return ( +
    +
    +

    Projects

    + {checkAdminOrCopilot(auth.token) && ( + + + + )} +
    +
    +
    +
    + +
    +
    + setSearch(e.target.value)} + value={search} + /> +
    +
    +
    +
    + +
    +
    + setOnlyMyProjects(!showOnlyMyProjects)} + /> + +
    + ) + } +
    +
    + {projects.length > 0 ? ( + <> +
      + {projects.map(p => ( +
    • + +
    • + ))} +
    + {projects && projects.length < projectsCount - 1 && ( + // fix + + )} + + ) : ( + No projects available yet + )} +
    + ) +} + +Projects.propTypes = { + projectsCount: PropTypes.number.isRequired, + projects: PropTypes.array, + auth: PropTypes.object.isRequired, + isLoading: PropTypes.bool.isRequired, + unloadProjects: PropTypes.func.isRequired, + loadProjects: PropTypes.func.isRequired, + loadMoreProjects: PropTypes.func.isRequired +} + +const mapStateToProps = ({ projects, auth }) => { + return { + projectsCount: projects.projectsCount, + projects: projects.projects, + isLoading: projects.isLoading, + auth + } +} + +const mapDispatchToProps = { + unloadProjects: unloadProjects, + loadProjects: loadProjects, + loadMoreProjects: loadMoreProjects +} + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(Projects) +) diff --git a/src/containers/Projects/styles.module.scss b/src/containers/Projects/styles.module.scss new file mode 100644 index 00000000..ef18461d --- /dev/null +++ b/src/containers/Projects/styles.module.scss @@ -0,0 +1,136 @@ +@import '../../styles/includes'; + +.container { + padding: 20px 20px 0px; + + ul { + background-color: $lighter-gray; + list-style: none; + width: 100%; + padding: 0; + margin-top: 0; + border-radius: 3px; + flex: 1; + } + + div, + a { + color: $dark-gray; + text-decoration: none; + } +} + +.headerLine { + display: flex; + gap: 5px; + padding-bottom: 15px; + justify-content: space-between; + align-items: center; + .buttonNewProject { + min-width: 169px; + height: 40px; + text-decoration: none; + + :global { + span { + margin: 0 20px; + } + } + } +} + +.searchWrapper { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: end; + .searchInput { + width: 100%; + height: 40px; + padding: 0 10px; + border-radius: 3px; + border: 1px solid $light-gray; + background-color: $lighter-gray; + } + + .tcCheckbox { + @include tc-checkbox; + + .tc-checkbox-label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + margin-left: 21px; + user-select: none; + cursor: pointer; + width: 195px; + font-size: 14px; + color: #3d3d3d; + } + + height: 18px; + width: 210px; + margin: 0; + padding: 0; + vertical-align: bottom; + position: relative; + display: inline-block; + margin-bottom: 4px; + margin-left: 8px; + + input[type=checkbox] { + display: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 18px; + height: 18px; + top: 0; + left: 0; + border: none; + box-shadow: none; + background: $tc-gray-30; + transition: all 0.15s ease-in-out; + + &::after { + opacity: 0; + content: ''; + position: absolute; + width: 9px; + height: 5px; + background: transparent; + top: 5px; + left: 5px; + border-top: none; + border-right: none; + transform: rotate(-45deg); + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 300px; + } + } + + input[type=checkbox]:checked ~ label { + background: $tc-blue-20; + } + + input[type=checkbox]:checked + label::after { + border-color: $white; + } + } +} \ No newline at end of file diff --git a/src/containers/TaaSList/index.js b/src/containers/TaaSList/index.js index 67610028..bdc58c50 100644 --- a/src/containers/TaaSList/index.js +++ b/src/containers/TaaSList/index.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { withRouter, Link } from 'react-router-dom' import { connect } from 'react-redux' import PropTypes from 'prop-types' @@ -7,15 +7,25 @@ import Loader from '../../components/Loader' import cn from 'classnames' import { checkAdmin, checkCopilot } from '../../util/tc' import { PrimaryButton } from '../../components/Buttons' +import InfiniteLoadTrigger from '../../components/InfiniteLoadTrigger' +import { loadProjects as _loadProjects, loadMoreProjects, unloadProjects as _unloadProjects } from '../../actions/projects' +import { PROJECT_TYPE_TAAS } from '../../config/constants' import styles from './styles.module.scss' -const TaaSList = ({ taasProjects, auth, isLoading }) => { +const TaaSList = ({ projects, auth, isLoading, projectsCount, loadProjects, loadMore, unloadProjects }) => { const isCopilot = checkCopilot(auth.token) const isAdmin = checkAdmin(auth.token) const canEdit = isCopilot || isAdmin - if (isLoading && taasProjects.length === 0) { + useEffect(() => { + loadProjects('', { type: PROJECT_TYPE_TAAS }) + }, []) + + // unload projects on dismount + useEffect(() => () => unloadProjects, []) + + if (isLoading && projects.length === 0) { return (
    @@ -26,7 +36,7 @@ const TaaSList = ({ taasProjects, auth, isLoading }) => { return (
    -
    No project selected. Select one below
    +

    Projects

    {(isCopilot || isAdmin) && ( { )}
    - {taasProjects.length > 0 ? ( -
      - {taasProjects.map(p => ( -
    • - -
    • - ))} -
    + {projects.length > 0 ? ( + <> +
      + {projects.map(p => ( +
    • + +
    • + ))} +
    + {projects && projects.length < projectsCount - 1 && ( + // fix + + )} + ) : ( No TaaS projects available yet )} @@ -57,20 +73,29 @@ const TaaSList = ({ taasProjects, auth, isLoading }) => { } TaaSList.propTypes = { - taasProjects: PropTypes.array, + projectsCount: PropTypes.number.isRequired, + projects: PropTypes.array, auth: PropTypes.object.isRequired, - isLoading: PropTypes.bool.isRequired + isLoading: PropTypes.bool.isRequired, + loadProjects: PropTypes.func.isRequired, + unloadProjects: PropTypes.func.isRequired, + loadMore: PropTypes.func.isRequired } -const mapStateToProps = ({ sidebar, auth }) => { +const mapStateToProps = ({ projects, auth }) => { return { - taasProjects: sidebar.taasProjects, - isLoading: sidebar.isLoading, + projectsCount: projects.projectsCount, + projects: projects.projects, + isLoading: projects.isLoading, auth } } -const mapDispatchToProps = {} +const mapDispatchToProps = { + loadProjects: _loadProjects, + unloadProjects: _unloadProjects, + loadMore: loadMoreProjects +} export default withRouter( connect(mapStateToProps, mapDispatchToProps)(TaaSList) diff --git a/src/containers/TaaSProjectForm/index.js b/src/containers/TaaSProjectForm/index.js index dc22e380..c583e8e0 100644 --- a/src/containers/TaaSProjectForm/index.js +++ b/src/containers/TaaSProjectForm/index.js @@ -156,6 +156,7 @@ const TaaSProjectForm = ({ setInitValues({ name: projectDetail.name, jobs: _.cloneDeep(taasJobs).map(item => ({ + jobId: item.jobId, title: item.title, people: parseInt(item.people), duration: parseInt(item.duration), diff --git a/src/containers/Tab/index.js b/src/containers/Tab/index.js index bf72d6df..22853e66 100644 --- a/src/containers/Tab/index.js +++ b/src/containers/Tab/index.js @@ -34,7 +34,7 @@ class TabContainer extends Component { !isLoading && !selfService && // do not fetch projects for users page - history.location.pathname !== '/users' + history.location.pathname === '/' ) { this.loadProjects(this.props) } @@ -45,7 +45,11 @@ class TabContainer extends Component { } componentWillReceiveProps (nextProps) { - const { projectId, isLoading, selfService, projects, isLoadProjectsSuccess } = nextProps + const { projectId, activeProjectId, isLoading, selfService, projects, isLoadProjectsSuccess } = nextProps + + if (projectId && activeProjectId < 0) { + this.props.setActiveProject(parseInt(projectId)) + } if (nextProps.history.location.pathname === '/') { this.setState({ currentTab: 1 }) @@ -95,9 +99,7 @@ class TabContainer extends Component { loadProjects (props) { const { history } = props - if (history.location.pathname === '/taas') { - this.props.loadProjects('', false, { type: 'talent-as-a-service', status: undefined }) - } else { + if (history.location.pathname === '/') { this.props.loadProjects() } } diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js index 7a1ecf3f..ab6b39f6 100644 --- a/src/containers/Users/index.js +++ b/src/containers/Users/index.js @@ -4,11 +4,12 @@ import _ from 'lodash' import PT from 'prop-types' import UsersComponent from '../../components/Users' import { PROJECT_ROLES } from '../../config/constants' -import { fetchProjectById } from '../../services/projects' -import { checkAdmin } from '../../util/tc' +import { fetchInviteMembers, fetchProjectById } from '../../services/projects' +import { checkAdmin, checkManager } from '../../util/tc' import { loadAllUserProjects, + loadNextProjects, searchUserProjects } from '../../actions/users' @@ -19,25 +20,41 @@ class Users extends Component { this.state = { loginUserRoleInProject: '', projectMembers: null, - isAdmin: false + invitedMembers: null, + isAdmin: false, + isLoadingProject: false } this.loadProject = this.loadProject.bind(this) - this.updateProjectNember = this.updateProjectNember.bind(this) - this.removeProjectNember = this.removeProjectNember.bind(this) + this.updateProjectMember = this.updateProjectMember.bind(this) + this.removeProjectMember = this.removeProjectMember.bind(this) + this.addNewProjectInvite = this.addNewProjectInvite.bind(this) this.addNewProjectMember = this.addNewProjectMember.bind(this) + this.loadNextProjects = this.loadNextProjects.bind(this) } componentDidMount () { - const { token, isLoading, loadAllUserProjects } = this.props + const { token, isLoading, loadAllUserProjects, page } = this.props if (!isLoading) { const isAdmin = checkAdmin(token) - loadAllUserProjects(isAdmin) + const isManager = checkManager(token) + const params = { + page + } + loadAllUserProjects(params, isAdmin, isManager) this.setState({ isAdmin }) } } + loadNextProjects () { + const { loadNextProjects: nextProjectsHandler, token } = this.props + const isAdmin = checkAdmin(token) + const isManager = checkManager(token) + + nextProjectsHandler(isAdmin, isManager) + } + isEditable () { const { loginUserRoleInProject } = this.state if (loginUserRoleInProject === PROJECT_ROLES.READ) { @@ -64,17 +81,27 @@ class Users extends Component { } loadProject (projectId) { - fetchProjectById(projectId).then((project) => { + this.setState({ isLoadingProject: true }) + fetchProjectById(projectId).then(async (project) => { const projectMembers = _.get(project, 'members') + const invitedMembers = _.get(project, 'invites') || [] + const invitedUserIds = _.filter(_.map(invitedMembers, 'userId')) + const invitedUsers = await fetchInviteMembers(invitedUserIds) + this.setState({ - projectMembers + projectMembers, + invitedMembers: invitedMembers.map(m => ({ + ...m, + email: m.email || invitedUsers[m.userId].handle + })), + isLoadingProject: false }) const { loggedInUser } = this.props this.updateLoginUserRoleInProject(projectMembers, loggedInUser) }) } - updateProjectNember (newMemberInfo) { + updateProjectMember (newMemberInfo) { const { projectMembers } = this.state const newProjectMembers = projectMembers.map(pm => pm.id === newMemberInfo.id ? ({ ...pm, @@ -87,12 +114,14 @@ class Users extends Component { this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser) } - removeProjectNember (projectMember) { - const { projectMembers } = this.state + removeProjectMember (projectMember) { + const { projectMembers, invitedMembers } = this.state const newProjectMembers = _.filter(projectMembers, pm => pm.id !== projectMember.id) + const newInvitedMembers = _.filter(invitedMembers, pm => pm.id !== projectMember.id) const { loggedInUser } = this.props this.setState({ - projectMembers: newProjectMembers + projectMembers: newProjectMembers, + invitedMembers: newInvitedMembers }) this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser) } @@ -110,6 +139,15 @@ class Users extends Component { this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser) } + addNewProjectInvite (invitedMember) { + this.setState(() => ({ + invitedMembers: [ + ...(this.state.invitedMembers || []), + invitedMember + ] + })) + } + render () { const { projects, @@ -120,16 +158,22 @@ class Users extends Component { } = this.props const { projectMembers, - isAdmin + invitedMembers, + isAdmin, + isLoadingProject } = this.state return ( { return { projects: users.allUserProjects, + page: users.page, isLoading: users.isLoadingAllUserProjects, resultSearchUserProjects: users.searchUserProjects, isSearchingUserProjects: users.isSearchingUserProjects, @@ -164,12 +209,15 @@ Users.propTypes = { isLoading: PT.bool, isSearchingUserProjects: PT.bool, loadAllUserProjects: PT.func.isRequired, - searchUserProjects: PT.func.isRequired + searchUserProjects: PT.func.isRequired, + loadNextProjects: PT.func.isRequired, + page: PT.number } const mapDispatchToProps = { loadAllUserProjects, - searchUserProjects + searchUserProjects, + loadNextProjects } export default connect(mapStateToProps, mapDispatchToProps)(Users) diff --git a/src/reducers/challengeSubmissions.js b/src/reducers/challengeSubmissions.js index 389c10a4..ab70bda7 100644 --- a/src/reducers/challengeSubmissions.js +++ b/src/reducers/challengeSubmissions.js @@ -11,7 +11,10 @@ const initialState = { isLoading: true, loadingId: null, challengeId: null, - challengeSubmissions: [] + challengeSubmissions: [], + totalSubmissions: 0, + submissionsPerPage: 10, + page: 1 } export default function (state = initialState, action) { @@ -19,10 +22,13 @@ export default function (state = initialState, action) { case LOAD_CHALLENGE_SUBMISSIONS_SUCCESS: return { ...state, - challengeSubmissions: action.payload, + challengeSubmissions: action.payload.data, isLoading: false, loadingId: null, - challengeId: state.loadingId + challengeId: state.loadingId, + totalSubmissions: action.payload.headers['x-total'], + page: action.payload.page, + submissionsPerPage: action.payload.perPage } case LOAD_CHALLENGE_SUBMISSIONS_PENDING: return { @@ -30,7 +36,8 @@ export default function (state = initialState, action) { isLoading: true, loadingId: action.challengeId, challengeId: null, - challengeSubmissions: [] + challengeSubmissions: [], + totalPages: 0 } case LOAD_CHALLENGE_SUBMISSIONS_FAILURE: return { @@ -38,7 +45,8 @@ export default function (state = initialState, action) { isLoading: false, loadingId: null, challengeId: null, - challengeSubmissions: [] + challengeSubmissions: [], + totalPages: 0 } default: return state diff --git a/src/reducers/projects.js b/src/reducers/projects.js index a6b94b9c..f81713c6 100644 --- a/src/reducers/projects.js +++ b/src/reducers/projects.js @@ -3,6 +3,10 @@ */ import _ from 'lodash' import { + LOAD_PROJECTS_PENDING, + LOAD_PROJECTS_SUCCESS, + LOAD_PROJECTS_FAILURE, + UNLOAD_PROJECTS_SUCCESS, LOAD_PROJECT_BILLING_ACCOUNTS_PENDING, LOAD_PROJECT_BILLING_ACCOUNTS_SUCCESS, LOAD_PROJECT_BILLING_ACCOUNTS_FAILURE, @@ -23,7 +27,13 @@ import { UPDATE_PROJECT_SUCCESS, UPDATE_PROJECT_DETAILS_FAILURE, UPDATE_PROJECT_DETAILS_PENDING, - UPDATE_PROJECT_DETAILS_SUCCESS + UPDATE_PROJECT_DETAILS_SUCCESS, + ADD_PROJECT_ATTACHMENT_SUCCESS, + UPDATE_PROJECT_ATTACHMENT_SUCCESS, + REMOVE_PROJECT_ATTACHMENT_SUCCESS, + LOAD_PROJECT_INVITES_PENDING, + LOAD_PROJECT_INVITES_SUCCESS, + LOAD_PROJECT_INVITES_FAILURE } from '../config/constants' import { toastSuccess, toastFailure } from '../util/toaster' import moment from 'moment-timezone' @@ -52,7 +62,7 @@ const dateFormat = 'MMM DD, YYYY' */ const buildBillingAccountOptions = (billingAccountObj) => { const billingAccountOptions = billingAccountObj.map(billingAccount => ({ - label: `(${billingAccount.tcBillingAccountId}) ${ + label: `[${billingAccount.tcBillingAccountId}] ${billingAccount.name} ${ billingAccount.endDate ? ' - ' + moment(billingAccount.endDate).format(dateFormat) : '' @@ -71,17 +81,51 @@ const initialState = { isBillingAccountExpired: false, isBillingAccountLoading: false, isBillingAccountLoadingFailed: false, + isProjectInvitationsLoading: false, currentBillingAccount: null, billingStartDate: null, billingEndDate: null, isPhasesLoading: false, phases: [], isProjectTypesLoading: false, - projectTypes: [] + projectFilters: {}, + projectTypes: [], + projects: [], + projectsCount: 0, + projectsPage: 0 } export default function (state = initialState, action) { switch (action.type) { + case LOAD_PROJECTS_PENDING: + return { ...state, isLoading: true } + case LOAD_PROJECTS_SUCCESS: + return { + ...state, + projectFilters: action.filters, + projects: action.projects, + projectsCount: action.total, + projectsPage: action.page, + isLoading: false + } + case UNLOAD_PROJECTS_SUCCESS: + return { + ...state, + isLoading: false, + projectFilters: {}, + projects: [], + projectsCount: 0, + projectsPage: 0 + } + case LOAD_PROJECTS_FAILURE: { + const errorMessage = _.get( + action.payload, + 'response.data.message', + 'Failed to load projects' + ) + toastFailure('Error', errorMessage) + return { ...state, isLoading: false } + } case LOAD_PROJECT_DETAILS_PENDING: return { ...state, isLoading: true } case LOAD_PROJECT_DETAILS_FAILURE: { @@ -221,6 +265,29 @@ export default function (state = initialState, action) { ...state, isProjectTypesLoading: false } + case LOAD_PROJECT_INVITES_PENDING: + return { + ...state, + projectDetail: { + ...state.projectDetail, + invites: [] + }, + isProjectInvitationsLoading: true + } + case LOAD_PROJECT_INVITES_SUCCESS: + return { + ...state, + projectDetail: { + ...state.projectDetail, + invites: action.payload + }, + isProjectInvitationsLoading: false + } + case LOAD_PROJECT_INVITES_FAILURE: + return { + ...state, + isProjectInvitationsLoading: false + } case UPDATE_PROJECT_PENDING: return { ...state, @@ -244,6 +311,34 @@ export default function (state = initialState, action) { ...state, isUpdatingProject: false } + case ADD_PROJECT_ATTACHMENT_SUCCESS: + return { + ...state, + projectDetail: { + ...state.projectDetail, + attachments: [...(state.projectDetail.attachments || []), action.payload] + } + } + case UPDATE_PROJECT_ATTACHMENT_SUCCESS: + return { + ...state, + projectDetail: { + ...state.projectDetail, + attachments: state.projectDetail.attachments.map(item => + item.id !== action.payload.id ? item : action.payload + ) + } + } + case REMOVE_PROJECT_ATTACHMENT_SUCCESS: + return { + ...state, + projectDetail: { + ...state.projectDetail, + attachments: state.projectDetail.attachments.filter( + item => item.id !== action.payload + ) + } + } default: return state } diff --git a/src/reducers/sidebar.js b/src/reducers/sidebar.js index abb6b049..5fac3a82 100644 --- a/src/reducers/sidebar.js +++ b/src/reducers/sidebar.js @@ -15,8 +15,10 @@ import { toastFailure } from '../util/toaster' const initialState = { activeProjectId: -1, isLoading: false, + projectFilters: {}, projects: [], - taasProjects: [], + projectsTotal: 0, + projectsPage: 0, isLoadProjectsSuccess: false } @@ -27,16 +29,15 @@ export default function (state = initialState, action) { case LOAD_PROJECTS_SUCCESS: return { ...state, - projects: _.filter(action.projects, p => p.type !== 'talent-as-a-service'), - taasProjects: _.filter(action.projects, { - type: 'talent-as-a-service' - }), + projects: action.projects, + projectsTotal: action.total, + projectsPage: action.page, isLoading: false, isLoggedIn: true, isLoadProjectsSuccess: true } case UNLOAD_PROJECTS_SUCCESS: - return { ...state, projects: [], isLoading: false, isLoggedIn: true, isLoadProjectsSuccess: false } + return { ...state, projectsTotal: 0, projectsPage: 0, projects: [], isLoading: false, isLoggedIn: true, isLoadProjectsSuccess: false } case LOAD_PROJECTS_PENDING: return { ...state, isLoading: true } case LOAD_PROJECTS_FAILURE: { diff --git a/src/reducers/users.js b/src/reducers/users.js index 8dc18247..e293c154 100644 --- a/src/reducers/users.js +++ b/src/reducers/users.js @@ -14,7 +14,9 @@ const initialState = { allUserProjects: [], isLoadingAllUserProjects: false, searchUserProjects: [], - isSearchingUserProjects: false + isSearchingUserProjects: false, + page: 1, + total: null } export default function (state = initialState, action) { @@ -23,7 +25,9 @@ export default function (state = initialState, action) { return { ...state, allUserProjects: action.projects, - isLoadingAllUserProjects: false + isLoadingAllUserProjects: false, + page: action.page, + total: action.total } case LOAD_ALL_USER_PROJECTS_PENDING: return { ...state, isLoadingAllUserProjects: true } diff --git a/src/routes.js b/src/routes.js index c26549d7..b830f9f6 100644 --- a/src/routes.js +++ b/src/routes.js @@ -11,7 +11,9 @@ import TopBarContainer from './containers/TopbarContainer' import FooterContainer from './containers/FooterContainer' import Tab from './containers/Tab' import Challenges from './containers/Challenges' +import Projects from './containers/Projects' import TaaSList from './containers/TaaSList' +import ProjectAssets from './containers/ProjectAssets' import TaaSProjectForm from './containers/TaaSProjectForm' import ChallengeEditor from './containers/ChallengeEditor' import { getFreshToken, decodeToken } from 'tc-auth-lib' @@ -31,6 +33,7 @@ import ConfirmationModal from './components/Modal/ConfirmationModal' import Users from './containers/Users' import { isBetaMode, removeFromLocalStorage, saveToLocalStorage } from './util/localstorage' import ProjectEditor from './containers/ProjectEditor' +import ProjectInvitations from './containers/ProjectInvitations' const { ACCOUNTS_APP_LOGIN_URL, IDLE_TIMEOUT_MINUTES, IDLE_TIMEOUT_GRACE_MINUTES, COMMUNITY_APP_URL } = process.env @@ -108,7 +111,7 @@ class Routes extends React.Component { } else { console.error('An unexpected error occurred while getting auth token') } - const redirectBackToUrl = encodeURIComponent(window.location.origin + this.props.location.pathname) + const redirectBackToUrl = encodeURIComponent(window.location.origin + this.props.location.pathname + this.props.location.search) window.location = `${ACCOUNTS_APP_LOGIN_URL}?retUrl=${redirectBackToUrl}` }) } @@ -194,7 +197,7 @@ class Routes extends React.Component { /> renderApp( - , + , , , @@ -208,6 +211,14 @@ class Routes extends React.Component { )()} /> + renderApp( + , + , + , + + )()} + /> renderApp( , @@ -216,6 +227,20 @@ class Routes extends React.Component { )()} /> + {(isCopilot || isAdmin) && ( + + renderApp( + , + , + , + + )() + } + /> + )} { !isReadOnly && ( } */ -export async function fetchSubmissions (challengeId) { - const response = await axiosInstance.get(`${SUBMISSIONS_API_URL}?challengeId=${challengeId}&perPage=100`) +export async function fetchSubmissions (challengeId, pageObj) { + const { page, perPage } = pageObj + const response = await axiosInstance.get(`${SUBMISSIONS_API_URL}?challengeId=${challengeId}&perPage=${perPage}&page=${page}`) + return { + data: _.get(response, 'data', []), + headers: _.get(response, 'headers', {}), + page, + perPage + } +} + +export async function getReviewTypes () { + const response = await axiosInstance.get(`${REVIEW_TYPE_API_URL}?perPage=500&page=1`) return _.get(response, 'data', []) } diff --git a/src/services/projectMemberInvites.js b/src/services/projectMemberInvites.js new file mode 100644 index 00000000..cfa9a660 --- /dev/null +++ b/src/services/projectMemberInvites.js @@ -0,0 +1,73 @@ +import { axiosInstance as axios } from './axiosWithAuth' +import { PROJECTS_API_URL } from '../config/constants' + +/** + * Update project member invite based on project's id & given member + * @param {integer} projectId unique identifier of the project + * @param {integer} inviteId unique identifier of the invite + * @param {string} status the new status for invitation + * @return {object} project member invite returned by api + */ +export function updateProjectMemberInvite (projectId, inviteId, status, source) { + const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}` + const body = { + status + } + + if (source) { + body.source = source + } + return axios.patch(url, body) + .then(resp => resp.data) +} + +/** + * Delete project member invite based on project's id & given invite's id + * @param {integer} projectId unique identifier of the project + * @param {integer} inviteId unique identifier of the invite + * @return {object} project member invite returned by api + */ +export function deleteProjectMemberInvite (projectId, inviteId) { + const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}` + return axios.delete(url) +} + +/** + * Create a project member invite based on project's id & given member + * @param {integer} projectId unique identifier of the project + * @param {object} member invite + * @return {object} project member invite returned by api + */ +export function createProjectMemberInvite (projectId, member) { + const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle' + const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + encodeURIComponent(fields) + return axios({ + method: 'post', + url, + data: member, + validateStatus (status) { + return (status >= 200 && status < 300) || status === 403 + } + }) + .then(resp => resp.data) +} + +export function getProjectMemberInvites (projectId) { + const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle' + const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + + encodeURIComponent(fields) + return axios.get(url) + .then(resp => { + return resp.data + }) +} + +/** + * Get a project member invite based on project's id + * @param {integer} projectId unique identifier of the project + * @return {object} project member invite returned by api + */ +export function getProjectInviteById (projectId) { + return axios.get(`${PROJECTS_API_URL}/${projectId}/invites`) + .then(resp => resp.data) +} diff --git a/src/services/projects.js b/src/services/projects.js index 2aa70898..a3cf43c0 100644 --- a/src/services/projects.js +++ b/src/services/projects.js @@ -2,12 +2,17 @@ import _ from 'lodash' import { axiosInstance } from './axiosWithAuth' import * as queryString from 'query-string' import { + ATTACHMENT_TYPE_FILE, + FILE_PICKER_SUBMISSION_CONTAINER_NAME, GENERIC_PROJECT_MILESTONE_PRODUCT_NAME, GENERIC_PROJECT_MILESTONE_PRODUCT_TYPE, PHASE_PRODUCT_CHALLENGE_ID_FIELD, - PHASE_PRODUCT_TEMPLATE_ID + PHASE_PRODUCT_TEMPLATE_ID, + PROJECTS_API_URL, + MEMBERS_API_URL } from '../config/constants' -const { PROJECT_API_URL } = process.env +import { paginationHeaders } from '../util/pagination' +import { createProjectMemberInvite } from './projectMemberInvites' /** * Get billing accounts based on project id @@ -17,7 +22,7 @@ const { PROJECT_API_URL } = process.env * @returns {Promise} Billing accounts data */ export async function fetchBillingAccounts (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccounts`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccounts`) return _.get(response, 'data') } @@ -29,7 +34,7 @@ export async function fetchBillingAccounts (projectId) { * @returns {Promise} Billing account data */ export async function fetchBillingAccount (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccount`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccount`) return _.get(response, 'data') } @@ -37,13 +42,21 @@ export async function fetchBillingAccount (projectId) { * Api request for fetching member's projects * @returns {Promise<*>} */ -export async function fetchMemberProjects (filters) { +export function fetchMemberProjects (filters) { const params = { ...filters } - const response = await axiosInstance.get(`${PROJECT_API_URL}?${queryString.stringify(params)}`) - return _.get(response, 'data') + for (let param in params) { + if (params[param] && Array.isArray(params[param])) { + params[`${param}[$in]`] = params[param] + params[param] = undefined + } + } + + return axiosInstance.get(`${PROJECTS_API_URL}?${queryString.stringify(params)}`).then(response => { + return { projects: _.get(response, 'data'), pagination: paginationHeaders(response) } + }) } /** @@ -52,17 +65,30 @@ export async function fetchMemberProjects (filters) { * @returns {Promise<*>} */ export async function fetchProjectById (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}`) return _.get(response, 'data') } +/** + * This fetches the user corresponding to the given userIds + * @param {*} userIds + */ +export async function fetchInviteMembers (userIds) { + const url = `${MEMBERS_API_URL}?${userIds.map(id => `userIds[]=${id}`).join('&')}` + const { data = [] } = await axiosInstance.get(url) + return data.reduce((acc, member) => { + acc[member.userId] = member + return acc + }, {}) +} + /** * Api request for fetching project phases * @param id Project id * @returns {Promise<*>} */ export async function fetchProjectPhases (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}/phases`, { + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}/phases`, { params: { fields: 'id,name,products,status' } @@ -78,7 +104,7 @@ export async function fetchProjectPhases (id) { * @returns {Promise<*>} */ export async function updateProjectMemberRole (projectId, memberRecordId, newRole) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`, { + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`, { role: newRole }) return _.get(response, 'data') @@ -92,13 +118,24 @@ export async function updateProjectMemberRole (projectId, memberRecordId, newRol * @returns {Promise<*>} */ export async function addUserToProject (projectId, userId, role) { - const response = await axiosInstance.post(`${PROJECT_API_URL}/${projectId}/members`, { + const response = await axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/members`, { userId, role }) return _.get(response, 'data') } +/** + * adds the given user to the given project with the specified role + * @param projectId project id + * @param userId user id + * @param role + * @returns {Promise<*>} + */ +export async function inviteUserToProject (projectId, params) { + return createProjectMemberInvite(projectId, params) +} + /** * removes the given member record from the project * @param projectId project id @@ -106,7 +143,7 @@ export async function addUserToProject (projectId, userId, role) { * @returns {Promise<*>} */ export async function removeUserFromProject (projectId, memberRecordId) { - const response = await axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`) + const response = await axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`) return response } @@ -133,7 +170,7 @@ export async function saveChallengeAsPhaseProduct (projectId, phaseId, challenge estimatedPrice: 1 } - return axiosInstance.post(`${PROJECT_API_URL}/${projectId}/phases/${phaseId}/products`, + return axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/phases/${phaseId}/products`, _.set(payload, PHASE_PRODUCT_CHALLENGE_ID_FIELD, challengeId) ) } @@ -164,7 +201,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { if (selectedMilestoneProduct) { // If its the only challenge in product and product doesn't contain any other detail just delete it - return axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) + return axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) } } @@ -174,7 +211,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { * @returns {Promise<*>} */ export async function createProjectApi (project) { - const response = await axiosInstance.post(`${PROJECT_API_URL}`, project) + const response = await axiosInstance.post(`${PROJECTS_API_URL}`, project) return _.get(response, 'data') } @@ -185,7 +222,7 @@ export async function createProjectApi (project) { * @returns {Promise<*>} */ export async function updateProjectApi (projectId, project) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}`, project) + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}`, project) return _.get(response, 'data') } @@ -194,6 +231,94 @@ export async function updateProjectApi (projectId, project) { * @returns {Promise<*>} */ export async function getProjectTypes () { - const response = await axiosInstance.get(`${PROJECT_API_URL}/metadata/projectTypes`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/metadata/projectTypes`) + return _.get(response, 'data') +} + +/** + * Get project invites + * @returns {Promise<*>} + */ +export async function getProjectInvites (projectId) { + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/invites`) + return _.get(response, 'data') +} + +/** + * Get project attachment + * @param projectId project id + * @param attachmentId attachment id + * @returns {Promise<*>} + */ +export async function getProjectAttachment (projectId, attachmentId) { + const response = await axiosInstance.get( + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` + ) return _.get(response, 'data') } + +/** + * Add attachment to project + * @param projectId project id + * @param data attachment data + * @returns {Promise<*>} + */ +export async function addProjectAttachmentApi (projectId, data) { + if (data.type === ATTACHMENT_TYPE_FILE) { + // add s3 bucket prop + data.s3Bucket = FILE_PICKER_SUBMISSION_CONTAINER_NAME + } + + // The api takes only arrays + if (!data.tags) { + data.tags = [] + } + + const response = await axiosInstance.post( + `${PROJECTS_API_URL}/${projectId}/attachments`, + data + ) + return _.get(response, 'data') +} + +/** + * Update project attachment + * @param projectId project id + * @param attachmentId attachment id + * @param attachment attachment data + * @returns {Promise<*>} + */ +export async function updateProjectAttachmentApi ( + projectId, + attachmentId, + attachment +) { + let data = { + ...attachment + } + if (data && (!data.allowedUsers || data.allowedUsers.length === 0)) { + data.allowedUsers = null + } + + // The api takes only arrays + if (data && !data.tags) { + data.tags = [] + } + + const response = await axiosInstance.patch( + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`, + data + ) + return _.get(response, 'data') +} + +/** + * Remove project attachment + * @param projectId project id + * @param attachmentId attachment id + */ +export async function removeProjectAttachmentApi (projectId, attachmentId) { + await axiosInstance.delete( + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` + ) +} diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss index b640d949..266755ae 100644 --- a/src/styles/_colors.scss +++ b/src/styles/_colors.scss @@ -39,6 +39,8 @@ $tc-blue-20: #2C95D7; $tc-blue-30: #2984BD; $tc-blue-40: #16679A; +$tc-dark-blue-10: #f4f9ff; + $tc-green-20: #43D7B0; $tc-green-30: #60C602; $tc-green-40: #35AC35; @@ -57,6 +59,7 @@ $tc-gray-50: #AAAAAA; $tc-gray-60: #979797; $tc-gray-70: #555555; $tc-gray-80: #2A2A2A; +$tc-gray-90: #2a2a2b; $tc-black: #000000; $tc-handle-yellow: #FDD615; diff --git a/src/util/delay.js b/src/util/delay.js new file mode 100644 index 00000000..398f3d08 --- /dev/null +++ b/src/util/delay.js @@ -0,0 +1,25 @@ +/** + * Creates a delay that can be awaited, and optionally aborted via an AbortSignal. + * + * @param {number} ms - The number of milliseconds to delay. + * @param {AbortSignal} signal - Optional AbortSignal to cancel the delay early. + * @returns A Promise that resolves after the delay, or rejects if aborted. + */ +export function delay (ms, signal) { + return new Promise((resolve, reject) => { + // Start a timer that will resolve the promise after `ms` milliseconds + const timeout = setTimeout(resolve, ms) + + // If an AbortSignal is provided, handle abort events + if (signal) { + // Listen for the 'abort' event + signal.addEventListener('abort', () => { + // Cancel the timeout so it doesn't resolve the promise later + clearTimeout(timeout) + // Reject the promise with a DOMException for consistency with other abortable APIs + // eslint-disable-next-line no-undef + reject(new DOMException('Delay aborted', 'AbortError')) + }, { once: true }) // 'once' ensures the handler is removed automatically after it runs + } + }) +} diff --git a/src/util/pagination.js b/src/util/pagination.js new file mode 100644 index 00000000..83361379 --- /dev/null +++ b/src/util/pagination.js @@ -0,0 +1,6 @@ +import { get, pick, camelCase } from 'lodash' + +export function paginationHeaders (response) { + const headers = pick(get(response, 'headers'), 'x-page', 'x-per-page', 'x-total', 'x-total-pages') + return Object.fromEntries(Object.entries(headers).map(([key, value]) => [camelCase(key), +value])) +} diff --git a/src/util/tc.js b/src/util/tc.js index a52abddd..699928b1 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -10,7 +10,8 @@ import { SUBMITTER_ROLE_UUID, READ_ONLY_ROLES, ALLOWED_DOWNLOAD_SUBMISSIONS_ROLES, - ALLOWED_EDIT_RESOURCE_ROLES + ALLOWED_EDIT_RESOURCE_ROLES, + MANAGER_ROLES } from '../config/constants' import _ from 'lodash' import { decodeToken } from 'tc-auth-lib' @@ -194,28 +195,51 @@ export const checkEditResourceRoles = resourceRoles => { * Checks if token has any of the admin roles * @param token */ -export const checkAdmin = token => { - const roles = _.get(decodeToken(token), 'roles') +export const checkAdmin = (token) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') return roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) } +export const checkManager = (token) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + return roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) +} /** * Checks if token has any of the copilot roles * @param token */ -export const checkCopilot = token => { - const roles = _.get(decodeToken(token), 'roles') - return roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) +export const checkCopilot = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) + const canManageProject = !project || _.isEmpty(project) || ALLOWED_EDIT_RESOURCE_ROLES.includes(_.get(_.find(project.members, { userId: tokenData.userId }), 'role')) + + return isCopilot && canManageProject } /** * Checks if token has any of the admin or copilot roles * @param token */ -export const checkAdminOrCopilot = token => { - const roles = _.get(decodeToken(token), 'roles') - const allowedRoles = [...ADMIN_ROLES, ...COPILOT_ROLES] - return roles.some(val => allowedRoles.indexOf(val.toLowerCase()) > -1) +export const checkAdminOrCopilot = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) + const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) + const canManageProject = !project || _.isEmpty(project) || ALLOWED_EDIT_RESOURCE_ROLES.includes(_.get(_.find(project.members, { userId: tokenData.userId }), 'role')) + + return isAdmin || (isCopilot && canManageProject) +} + +export const checkIsUserInvitedToProject = (token, project) => { + if (!token) { + return + } + + const tokenData = decodeToken(token) + return project && !_.isEmpty(project) && (_.find(project.invites, d => d.userId === tokenData.userId || d.email === tokenData.email)) } /** @@ -324,3 +348,23 @@ export function getChallengeTypeAbbr (track, challengeTypes) { export function is2RoundsChallenge (challenge) { return !!_.find(challenge.phases, { name: 'Checkpoint Submission' }) } + +/** + * Get full name of user + * @param {Object} user user info + * @returns + */ +export function getFullNameWithFallback (user) { + if (!user) return '' + let userFullName = user.firstName + if (userFullName && user.lastName) { + userFullName += ' ' + user.lastName + } + userFullName = + userFullName && userFullName.trim().length > 0 ? userFullName : user.handle + userFullName = + userFullName && userFullName.trim().length > 0 + ? userFullName + : 'Connect user' + return userFullName +} diff --git a/src/util/validation.js b/src/util/validation.js index c0398531..9d6cb6ac 100644 --- a/src/util/validation.js +++ b/src/util/validation.js @@ -54,3 +54,30 @@ export const taaSProjectFormValidationSchema = Yup.object({ }) ) }) + +/** + * regex for url validation + */ +const urlRegex = /((https?):\/\/)?(www\.)?[\w-]+(\.[a-z]{2,}){1,3}(#?\/?(?:[a-zA-Z0-9#-]+))*\/?(\?[a-zA-Z0-9-_]+=[a-zA-Z0-9-%]+&?)?$/ + +/** + * validation schema for add link form in assets library + */ +export const assetsLibraryAddLinkSchema = Yup.object({ + title: Yup.string() + .trim() + .required('Name is required'), + path: Yup.string() + .trim() + .required('URL is required') + .matches(urlRegex, 'Please enter a valid URL') +}) + +/** + * validation schema for edit file form in assets library + */ +export const assetsLibraryEditFileSchema = Yup.object({ + title: Yup.string() + .trim() + .required('Title is required') +})