diff --git a/config/constants/development.js b/config/constants/development.js index df892660..b24ee6d4 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -46,5 +46,8 @@ module.exports = { IDLE_TIMEOUT_MINUTES: 10, // duration to show the prompt saying user will be logged out, before actually logging out the user IDLE_TIMEOUT_GRACE_MINUTES: 5, - MULTI_ROUND_CHALLENGE_TEMPLATE_ID: 'd4201ca4-8437-4d63-9957-3f7708184b07' + MULTI_ROUND_CHALLENGE_TEMPLATE_ID: 'd4201ca4-8437-4d63-9957-3f7708184b07', + UNIVERSAL_NAV_URL: '//uni-nav.topcoder-dev.com/v1/tc-universal-nav.js', + HEADER_AUTH_URLS_HREF: `https://accounts-auth0.${DOMAIN}?utm_source=community-app-main`, + HEADER_AUTH_URLS_LOCATION: `https://accounts-auth0.${DOMAIN}?retUrl=%S&utm_source=community-app-main` } diff --git a/config/constants/production.js b/config/constants/production.js index 0ac72c1e..a572b1d5 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -44,5 +44,8 @@ module.exports = { FILE_PICKER_CNAME: 'fs.topcoder.com', IDLE_TIMEOUT_MINUTES: 10, IDLE_TIMEOUT_GRACE_MINUTES: 5, - MULTI_ROUND_CHALLENGE_TEMPLATE_ID: 'd4201ca4-8437-4d63-9957-3f7708184b07' + MULTI_ROUND_CHALLENGE_TEMPLATE_ID: 'd4201ca4-8437-4d63-9957-3f7708184b07', + UNIVERSAL_NAV_URL: '//uni-nav.topcoder.com/v1/tc-universal-nav.js', + HEADER_AUTH_URLS_HREF: `https://accounts-auth0.${DOMAIN}?utm_source=community-app-main`, + HEADER_AUTH_URLS_LOCATION: `https://accounts-auth0.${DOMAIN}?retUrl=%S&utm_source=community-app-main` } diff --git a/package-lock.json b/package-lock.json index 65c76e8a..61b9bb51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3459,14 +3459,12 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "optional": true + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "optional": true, "requires": { "is-extglob": "^2.1.1" } @@ -3480,8 +3478,7 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "optional": true + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, "to-regex-range": { "version": "5.0.1", @@ -6443,8 +6440,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -6462,13 +6458,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6481,18 +6475,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -6595,8 +6586,7 @@ }, "inherits": { "version": "2.0.4", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -6606,7 +6596,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6619,20 +6608,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "1.2.5", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.9.0", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6649,7 +6635,6 @@ "mkdirp": { "version": "0.5.3", "bundled": true, - "optional": true, "requires": { "minimist": "^1.2.5" } @@ -6705,8 +6690,7 @@ }, "npm-normalize-package-bin": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "npm-packlist": { "version": "1.4.8", @@ -6731,8 +6715,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -6742,7 +6725,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -6811,8 +6793,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -6842,7 +6823,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6860,7 +6840,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6899,13 +6878,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.1.1", - "bundled": true, - "optional": true + "bundled": true } } }, @@ -10747,8 +10724,7 @@ "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "optional": true + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" }, "pify": { "version": "2.3.0", @@ -17574,8 +17550,8 @@ } }, "tc-auth-lib": { - "version": "github:topcoder-platform/tc-auth-lib#68fdc22464810c51b703a33e529cdbd6d09437de", - "from": "github:topcoder-platform/tc-auth-lib#1.0.4", + "version": "git+ssh://git@github.com/topcoder-platform/tc-auth-lib.git#68fdc22464810c51b703a33e529cdbd6d09437de", + "from": "tc-auth-lib@topcoder-platform/tc-auth-lib#1.0.4", "requires": { "lodash": "^4.17.19" }, @@ -18371,8 +18347,7 @@ "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "optional": true + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" }, "binary-extensions": { "version": "1.13.1", @@ -18384,7 +18359,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "optional": true, "requires": { "arr-flatten": "^1.1.0", "array-unique": "^0.3.2", @@ -18514,7 +18488,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "optional": true, "requires": { "is-extendable": "^0.1.0" } @@ -18550,7 +18523,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "optional": true, "requires": { "extend-shallow": "^2.0.1", "is-number": "^3.0.0", @@ -18644,8 +18616,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "optional": true + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-glob": { "version": "4.0.1", @@ -18660,7 +18631,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "optional": true, "requires": { "kind-of": "^3.0.2" } diff --git a/package.json b/package.json index 02e50216..390f28ae 100644 --- a/package.json +++ b/package.json @@ -175,5 +175,8 @@ }, "devDependencies": { "standard": "^12.0.1" + }, + "volta": { + "node": "10.15.3" } } diff --git a/server.js b/server.js index eaccf988..f5fb9cf6 100644 --- a/server.js +++ b/server.js @@ -17,17 +17,22 @@ function check () { } app.use(healthCheck.middleware([check])) app.use((req, res, next) => { - res.header('Referrer-Policy', 'strict-origin-when-cross-origin'); - res.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); - res.header('X-Content-Type-Options', 'nosniff'); - res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); - res.header('Cache-control', 'public, max-age=0'); - res.header('Pragma', 'no-cache'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('Content-Security-Policy', "frame-ancestors 'none';"); + res.header('Referrer-Policy', 'strict-origin-when-cross-origin') + res.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()') + res.header('X-Content-Type-Options', 'nosniff') + res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload') + res.header('Cache-control', 'public, max-age=0') + res.header('Pragma', 'no-cache') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('Content-Security-Policy', + "frame-ancestors 'none';" + + "script-src 'report-sample' 'self' 'unsafe-inline' 'unsafe-eval'" + + ' https://uni-nav.topcoder-dev.com' + + ' https://uni-nav.topcoder.com' + ) - next(); -}); + next() +}) // app.use(requireHTTPS) // removed because app servers don't handle https // app.use(express.static(__dirname)) app.use(express.static(path.join(__dirname, 'build'))) diff --git a/src/actions/challenges.js b/src/actions/challenges.js index 943cc558..f9eb9771 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -59,29 +59,87 @@ import { removeChallengeFromPhaseProduct, saveChallengeAsPhaseProduct } from '.. /** * Loads active challenges of project by page */ -export function loadChallengesByPage (page, projectId, status, filterChallengeName = null, selfService = false, userHandle = null) { +export function loadChallengesByPage ( + page, + projectId, + status, + filterChallengeName = null, + selfService = false, + userHandle = null, + filterChallengeType = {}, + filterDate = {}, + filterSortBy = null, + filterSortOrder = null, + perPage = PAGE_SIZE +) { return (dispatch, getState) => { - dispatch({ - type: LOAD_CHALLENGES_PENDING, - challenges: [], - projectId: projectId, - status, - filterChallengeName, - perPage: PAGE_SIZE, - page - }) + if (_.isObject(projectId)) { + dispatch({ + type: LOAD_CHALLENGES_PENDING, + challenges: [], + status, + filterProjectOption: projectId, + filterChallengeName, + filterChallengeType, + filterDate, + filterSortBy, + filterSortOrder, + perPage, + page + }) + } else { + dispatch({ + type: LOAD_CHALLENGES_PENDING, + challenges: [], + status, + projectId, + filterChallengeName, + filterChallengeType, + filterDate, + filterSortBy, + filterSortOrder, + perPage, + page + }) + } const filters = { - sortBy: 'updated', + sortBy: 'startDate', sortOrder: 'desc' } + if (_.isObject(filterChallengeType) && filterChallengeType.value) { + filters['type'] = filterChallengeType.value + } + if (_.isObject(filterDate) && filterDate.startDateStart) { + filters['startDateStart'] = filterDate.startDateStart + } + if (_.isObject(filterDate) && filterDate.startDateEnd) { + filters['startDateEnd'] = filterDate.startDateEnd + } + if (_.isObject(filterDate) && filterDate.endDateStart) { + filters['endDateStart'] = filterDate.endDateStart + } + if (_.isObject(filterDate) && filterDate.endDateEnd) { + filters['endDateEnd'] = filterDate.endDateEnd + } + if (filterSortBy) { + filters['sortBy'] = filterSortBy + } + if (filterSortOrder) { + filters['sortOrder'] = filterSortOrder + } if (!_.isEmpty(filterChallengeName)) { filters['name'] = filterChallengeName } if (_.isInteger(projectId) && projectId > 0) { filters['projectId'] = projectId + } else if (_.isObject(projectId) && projectId.value > 0) { + filters['projectId'] = projectId.value } - if (!_.isEmpty(status)) { + + if (status === 'all') { + delete filters['status'] + } else if (!_.isEmpty(status)) { filters['status'] = status === '' ? undefined : _.startCase(status.toLowerCase()) } else if (!(_.isInteger(projectId) && projectId > 0)) { filters['status'] = 'Active' @@ -95,7 +153,7 @@ export function loadChallengesByPage (page, projectId, status, filterChallengeNa return fetchChallenges(filters, { page, - perPage: PAGE_SIZE + perPage // memberId: getState().auth.user ? getState().auth.user.userId : null }).then((res) => { dispatch({ @@ -113,14 +171,24 @@ export function loadChallengesByPage (page, projectId, status, filterChallengeNa /** * Loads active challenges of project */ -export function loadChallenges (projectId, status, filterChallengeName = null) { +export function loadChallenges ( + projectId, + status, + filterChallengeName = null, + filterChallengeType = null, + filterSortBy, + filterSortOrder +) { return (dispatch, getState) => { dispatch({ type: LOAD_CHALLENGES_PENDING, challenges: [], projectId: projectId ? `${projectId}` : '', status, - filterChallengeName + filterChallengeName, + filterChallengeType, + filterSortBy, + filterSortOrder }) const filters = {} diff --git a/src/actions/projects.js b/src/actions/projects.js index b0ae5943..db7f5042 100644 --- a/src/actions/projects.js +++ b/src/actions/projects.js @@ -2,23 +2,28 @@ import { LOAD_PROJECT_BILLING_ACCOUNT, LOAD_CHALLENGE_MEMBERS_SUCCESS, LOAD_PROJECT_DETAILS, - LOAD_PROJECT_PHASES + LOAD_PROJECT_PHASES, + LOAD_CHALLENGE_MEMBERS } from '../config/constants' -import { fetchProjectById, fetchBillingAccount, fetchProjectPhases } from '../services/projects' +import { + fetchProjectById, + fetchBillingAccount, + fetchProjectPhases +} from '../services/projects' /** * Loads project details */ -export function loadProject (projectId) { +export function loadProject (projectId, filterMembers = true) { return (dispatch, getState) => { return dispatch({ type: LOAD_PROJECT_DETAILS, payload: fetchProjectById(projectId).then((project) => { if (project && project.members) { - const members = project.members.filter(m => m.role === 'manager' || m.role === 'copilot') + const members = filterMembers ? project.members.filter(m => m.role === 'manager' || m.role === 'copilot') : project.members dispatch({ type: LOAD_CHALLENGE_MEMBERS_SUCCESS, - members + payload: members }) } @@ -39,3 +44,18 @@ export function loadProject (projectId) { }) } } + +export function reloadProjectMembers (projectId) { + return (dispatch) => { + return dispatch({ + type: LOAD_CHALLENGE_MEMBERS, + payload: fetchProjectById(projectId) + .then((project) => { + if (project && project.members) { + return project.members + } + return [] + }) + }) + } +} diff --git a/src/assets/images/ico-arrow-down.svg b/src/assets/images/ico-arrow-down.svg new file mode 100644 index 00000000..3ba23d39 --- /dev/null +++ b/src/assets/images/ico-arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/nav-active-item.svg b/src/assets/images/nav-active-item.svg new file mode 100644 index 00000000..5ff4ba65 --- /dev/null +++ b/src/assets/images/nav-active-item.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/sort-icon.svg b/src/assets/images/sort-icon.svg new file mode 100644 index 00000000..1defb57d --- /dev/null +++ b/src/assets/images/sort-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/App/index.js b/src/components/App/index.js index 316f2e65..6ebe64c0 100644 --- a/src/components/App/index.js +++ b/src/components/App/index.js @@ -2,19 +2,18 @@ * Component that sets the general structure of the app */ import React from 'react' -import TwoColsLayout from '../TwoColsLayout' +import TwoRowsLayout from '../TwoRowsLayout' -const App = (content, topbar, sidebar) => () => { +const App = (content, topbar, sidebar, footer) => () => { return ( - - - {sidebar} - - + + {topbar || null} + {sidebar} {content} - - + {footer || null} + + ) } diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index fae88c61..39f6b1e8 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -207,7 +207,14 @@ const ChallengeView = ({ )} { - phases.map((phase, index) => ( + challenge.legacy.subTrack === 'WEB_DESIGNS' && challenge.phases.length === 8 ? phases.map((phase, index) => ( + + )) : _.sortBy(phases, ['scheduledEndDate']).map((phase, index) => ( - {(isDraft || challenge.status === 'New') && !isSelfService && + {enableEdit && (isDraft || challenge.status === 'New') && !isReadOnly && !isSelfService && (
)} {canLaunch && (
diff --git a/src/components/ChallengeEditor/CheckpointPrizes-Field/index.js b/src/components/ChallengeEditor/CheckpointPrizes-Field/index.js index dda04a45..b5028ced 100644 --- a/src/components/ChallengeEditor/CheckpointPrizes-Field/index.js +++ b/src/components/ChallengeEditor/CheckpointPrizes-Field/index.js @@ -77,7 +77,8 @@ const CheckpointPrizesField = ({ challenge, onUpdateOthers, readOnly }) => { } CheckpointPrizesField.defaultProps = { - readOnly: false + readOnly: false, + onUpdateOthers: () => {} } CheckpointPrizesField.propTypes = { diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js index e6436898..3db1b6db 100644 --- a/src/components/ChallengeEditor/index.js +++ b/src/components/ChallengeEditor/index.js @@ -27,7 +27,8 @@ import { MILESTONE_STATUS, PHASE_PRODUCT_CHALLENGE_ID_FIELD, QA_TRACK_ID, DESIGN_CHALLENGE_TYPES, ROUND_TYPES, - MULTI_ROUND_CHALLENGE_TEMPLATE_ID, DS_TRACK_ID + MULTI_ROUND_CHALLENGE_TEMPLATE_ID, DS_TRACK_ID, + CHALLENGE_STATUS } from '../../config/constants' import { getDomainTypes, getResourceRoleByName } from '../../util/tc' import { PrimaryButton, OutlineButton } from '../Buttons' @@ -102,7 +103,8 @@ class ChallengeEditor extends Component { // NOTE that we have to keep `assignedMemberDetails` in the local state, rather than just get it from the props // because we can update it locally when we choose another assigned user, so we don't have to wait for user details // to be loaded from Member Service as we already know it in such case - assignedMemberDetails: this.props.assignedMemberDetails + assignedMemberDetails: this.props.assignedMemberDetails, + isPhaseChange: false } this.onUpdateInput = this.onUpdateInput.bind(this) this.onUpdateSelect = this.onUpdateSelect.bind(this) @@ -838,7 +840,11 @@ class ChallengeEditor extends Component { for (let index = 0; index < phases.length; ++index) { newChallenge.phases[index].isDurationActive = moment(newChallenge.phases[index]['scheduledEndDate']).isAfter() - newChallenge.phases[index].isStartTimeActive = index <= 0 + if (newChallenge.phases[index].name === 'Submission' || newChallenge.phases[index].name === 'Checkpoint Submission') { + newChallenge.phases[index].isStartTimeActive = true + } else { + newChallenge.phases[index].isStartTimeActive = index <= 0 + } newChallenge.phases[index].isOpen = newChallenge.phases[index].isDurationActive } @@ -847,27 +853,31 @@ class ChallengeEditor extends Component { } onUpdatePhaseDate (phase, index) { + console.log('onUpdatePhase', phase, index) const { phases } = this.state.challenge let newChallenge = _.cloneDeep(this.state.challenge) + if (phase.isBlur && newChallenge.phases[index]['name'] === 'Submission') { newChallenge.phases[index]['duration'] = _.max([ newChallenge.phases[index - 1]['duration'], phase.duration ]) + newChallenge.phases[index]['scheduledStartDate'] = moment(phase.startDate).toISOString() newChallenge.phases[index]['scheduledEndDate'] = moment(newChallenge.phases[index]['scheduledStartDate']) .add(newChallenge.phases[index]['duration'], 'hours') .format('MM/DD/YYYY HH:mm') } else { newChallenge.phases[index]['duration'] = phase.duration - newChallenge.phases[index]['scheduledStartDate'] = phase.startDate - newChallenge.phases[index]['scheduledEndDate'] = phase.endDate + newChallenge.phases[index]['scheduledStartDate'] = moment(phase.startDate).toISOString() + newChallenge.phases[index]['scheduledEndDate'] = moment(phase.endDate).toISOString() } for (let phaseIndex = index + 1; phaseIndex < phases.length; ++phaseIndex) { - if (newChallenge.phases[phaseIndex]['name'] === 'Submission') { - newChallenge.phases[phaseIndex]['scheduledStartDate'] = - newChallenge.phases[phaseIndex - 1]['scheduledStartDate'] + if (newChallenge.phases[phaseIndex]['name'] === 'Submission' || newChallenge.phases[index].name === 'Checkpoint Submission') { + console.log('Setting submission phase scheduled start date', moment(phase.startDate).toISOString()) + newChallenge.phases[index]['scheduledStartDate'] = moment(phase.startDate).toISOString() + newChallenge.phases[phaseIndex]['duration'] = _.max([ newChallenge.phases[phaseIndex - 1]['duration'], newChallenge.phases[phaseIndex]['duration'] @@ -881,7 +891,11 @@ class ChallengeEditor extends Component { .add(newChallenge.phases[phaseIndex]['duration'], 'hours') .format('MM/DD/YYYY HH:mm') } - + if (!_.isEqual(newChallenge.phases[index], phases[index])) { + this.setState({ isPhaseChange: true }) + } + console.log('Setting new state', newChallenge) + console.log('isPhaseChange', this.state.isPhaseChange) this.setState({ challenge: newChallenge }) setTimeout(() => { @@ -890,6 +904,7 @@ class ChallengeEditor extends Component { } collectChallengeData (status) { + const { isPhaseChange } = this.state const { attachments, metadata } = this.props const challenge = pick([ 'phases', @@ -928,16 +943,23 @@ class ChallengeEditor extends Component { if (this.state.challenge.id) { challenge.attachmentIds = _.map(attachments, item => item.id) } + console.log('Phase Data', challenge.phases) challenge.phases = challenge.phases.map((p) => pick([ 'duration', 'phaseId', 'scheduledStartDate', 'scheduledEndDate' ], p)) + if (challenge.terms && challenge.terms.length === 0) delete challenge.terms delete challenge.attachments delete challenge.reviewType - return _.cloneDeep(challenge) + if (!isPhaseChange) delete challenge.phases + + const cloned = _.cloneDeep(challenge) + console.log('CLONED', cloned) + + return cloned } goToEdit (challengeID) { @@ -1341,6 +1363,7 @@ class ChallengeEditor extends Component { } const isTask = _.get(challenge, 'task.isTask', false) const { assignedMemberDetails, error } = this.state + const communityAppUrl = `${COMMUNITY_APP_URL}/challenges/${challenge.id}` let isActive = false let isDraft = false let isCompleted = false @@ -1486,14 +1509,16 @@ class ChallengeEditor extends Component { } theme={theme} closeText='Close' - closeLink='/' - okText='View Challenge' - okLink='./view' + closeLink='./view' + okText='Preview' + onOk={() => { + window.open(communityAppUrl, '_blank') + }} onClose={this.resetModal} /> ) } - + const statusMessage = challenge.status && challenge.status.split(' ')[0].toUpperCase() const errorContainer =
{error}
const actionButtons = @@ -1534,9 +1559,11 @@ class ChallengeEditor extends Component { )}
)} -
- -
+ {statusMessage !== CHALLENGE_STATUS.CANCELLED && +
+ +
+ } } {!isLoading && isActive &&
diff --git a/src/components/ChallengesComponent/ChallengeCard/ChallengeCard.module.scss b/src/components/ChallengesComponent/ChallengeCard/ChallengeCard.module.scss index 91f3213d..0a17fa64 100644 --- a/src/components/ChallengesComponent/ChallengeCard/ChallengeCard.module.scss +++ b/src/components/ChallengesComponent/ChallengeCard/ChallengeCard.module.scss @@ -1,144 +1,63 @@ @import "../../../styles/includes"; .item { - width: 100%; display: flex; - justify-content: space-between; - font-size: 16px; + font-size: 14px; + gap: 30px; height: 100%; + padding: 0 20px; + .editingContainer { display: none; } .iconsContainer { display: flex; } - &:hover {@import "../../../styles/includes"; - - .item { - width: 100%; - display: flex; - justify-content: space-between; - font-size: 16px; - height: 100%; - &:hover { - cursor: pointer; - background-color: $lighter-gray; - } - - .block { - color: $text-color; - word-break: break-all; - } - - .light-text { - color: $light-text; - } - - .linkGroup { - display: flex; - justify-content: space-between; - width: 100%; - padding-left: 20px; - padding-right: 20px; - - .link, - .link:hover, - .link:visited { - color: $status-blue; - margin: 0; - text-decoration-line: underline; - } - - span.link, - span.link:hover, - span.link:visited { - color: $inactive; - cursor: default; - } - - &.onlyOne { - justify-content: center; - } - } - - - } - - .icon { - vertical-align: bottom; - } - - .faIcon { - color: $gray; - margin-right: 10px; - margin-left: 20px; - } - - .faIconContainer { - flex:1; - width: 100%; - display: flex; - justify-items: flex-end; - align-items: center; - - span { - flex-grow: 1; - flex-shrink: 0; - color: $text-color; - } - } - - .col1 { - flex: 6; - flex-wrap: nowrap; - display: flex; - text-decoration: none; - .name { - flex:1; - display: flex; - flex-direction: column; - } + .col1 { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + } - align-items: center; - padding-left: 20px; - } + .col2 { + display: flex; + flex: 2; + flex-direction: column; + justify-content: center; - .col2 { - flex: 2; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-decoration: none; + span { + word-break: break-all; } + } - .col3 { - flex: 2; - display: flex; - flex-direction: column; - justify-content: center; - } + .col3 { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + } - .col4 { - flex: 2; - display: flex; - flex-wrap: wrap; - width: 100%; - align-items: center; - padding-right: 20px; - justify-content: center; - } + .col4 { + display: flex; + width: 30px; + flex-direction: column; + justify-content: center; + } - cursor: pointer; - background-color: $lighter-gray; + .col5 { + display: flex; + width: 80px; + flex-direction: column; + justify-content: center; + } - .editingContainer { - display: flex; - } - .iconsContainer { - display: none; - } + .col6 { + display: flex; + width: 40px; + flex-direction: column; + justify-content: center; } .block { @@ -305,54 +224,6 @@ } } -.col1 { - flex: 6; - flex-wrap: nowrap; - display: flex; - - .name { - flex:1; - display: flex; - flex-direction: column; - } - - align-items: center; - padding-left: 20px; -} - -.col2 { - flex: 2; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - .statusText { - color: $text-color; - font-size: 14px; - overflow-wrap: break-word; - word-wrap: break-word; - //hyphens: auto; - max-width: 100px; - } -} - -.col3 { - flex: 2; - display: flex; - flex-direction: column; - justify-content: center; -} - -.col4 { - flex: 2; - display: flex; - flex-wrap: wrap; - width: 100%; - align-items: center; - padding-right: 20px; -} - .modalContainer { padding: 0; position: fixed; diff --git a/src/components/ChallengesComponent/ChallengeCard/index.js b/src/components/ChallengesComponent/ChallengeCard/index.js index 80d5fc30..47d5dc6a 100644 --- a/src/components/ChallengesComponent/ChallengeCard/index.js +++ b/src/components/ChallengesComponent/ChallengeCard/index.js @@ -8,15 +8,13 @@ import cn from 'classnames' import { withRouter, Link } from 'react-router-dom' import moment from 'moment' import 'moment-duration-format' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faFile, faUser } from '@fortawesome/free-solid-svg-icons' import ChallengeStatus from '../ChallengeStatus' import ChallengeTag from '../ChallengeTag' import styles from './ChallengeCard.module.scss' -import { getFormattedDuration, formatDate } from '../../../util/date' -import { CHALLENGE_STATUS, COMMUNITY_APP_URL, DIRECT_PROJECT_URL, MESSAGE, ONLINE_REVIEW_URL } from '../../../config/constants' +import { formatDate } from '../../../util/date' +import { CHALLENGE_STATUS, COMMUNITY_APP_URL, DIRECT_PROJECT_URL, MESSAGE, ONLINE_REVIEW_URL, PROJECT_ROLES } from '../../../config/constants' import ConfirmationModal from '../../Modal/ConfirmationModal' -import { checkChallengeEditPermission } from '../../../util/tc' +import { checkChallengeEditPermission, checkReadOnlyRoles } from '../../../util/tc' import AlertModal from '../../Modal/AlertModal' import Tooltip from '../../Tooltip' @@ -24,76 +22,9 @@ const theme = { container: styles.modalContainer } -const STALLED_MSG = 'Stalled' -const DRAFT_MSG = 'In Draft' -const STALLED_TIME_LEFT_MSG = 'Challenge is currently on hold' -const FF_TIME_LEFT_MSG = 'Winner is working on fixes' - const PERMISSION_DELETE_MESSAGE_ERROR = "You don't have permission to delete this challenge" -/** - * Format the remaining time of a challenge phase - * @param phase Challenge phase - * @param status Challenge status - * @returns {*} - */ -const getTimeLeft = (phase, status) => { - if (!phase) return STALLED_TIME_LEFT_MSG - if (phase.phaseType === 'Final Fix') { - return FF_TIME_LEFT_MSG - } - let time = moment(phase.scheduledEndDate).diff() - const late = time < 0 - if (late) time = -time - - if (status !== CHALLENGE_STATUS.COMPLETED.toLowerCase()) { - const duration = getFormattedDuration(time) - return late ? `Late by ${duration}` : `${duration} to go` - } - - return moment(phase.scheduledEndDate).format('DD/MM/YYYY') -} - -/** - * Find current phase and remaining time of it - * @param c Challenge - * @returns {{phaseMessage: string, endTime: {late, text}}} - */ -const getPhaseInfo = (c) => { - const { currentPhaseNames, status, startDate, phases } = c - /* let checkPhases = (currentPhases && currentPhases.length > 0 ? currentPhases : allPhases) - if (_.isEmpty(checkPhases)) checkPhases = [] - let statusPhase = checkPhases - .filter(p => p.phaseType !== 'Registration') - .sort((a, b) => moment(a.scheduledEndTime).diff(b.scheduledEndTime))[0] - - if (!statusPhase && subTrack === 'FIRST_2_FINISH' && checkPhases.length) { - statusPhase = Object.clone(checkPhases[0]) - statusPhase.phaseType = 'Submission' - } */ - let phaseMessage = STALLED_MSG - // if (statusPhase) phaseMessage = statusPhase.phaseType - // else if (status === 'DRAFT') phaseMessage = DRAFT_MSG - var lowerStatus = status.toLowerCase() - if (lowerStatus === 'draft') { - phaseMessage = DRAFT_MSG - } else if (lowerStatus === 'active') { - if (!currentPhaseNames || currentPhaseNames.length === 0) { - var timeToStart = moment(startDate).diff() - if (timeToStart > 0) { - phaseMessage = `Scheduled in ${getFormattedDuration(timeToStart)}` - } - } else { - phaseMessage = currentPhaseNames.join('/') - } - } - const activePhases = phases.filter(p => !!p.isOpen) - const activePhase = activePhases.length > 0 ? activePhases[0] : null - const endTime = getTimeLeft(activePhase, lowerStatus) - return { phaseMessage, endTime } -} - /** * Render components when mouse hover * @param challenge @@ -164,28 +95,21 @@ const hoverComponents = (challenge, onUpdateLaunch, deleteModalLaunch) => { } const renderStatus = (status, getStatusText) => { - switch (status) { + const statusMessage = status.split(' ')[0] + switch (statusMessage) { case CHALLENGE_STATUS.ACTIVE: case CHALLENGE_STATUS.APPROVED: case CHALLENGE_STATUS.NEW: case CHALLENGE_STATUS.DRAFT: case CHALLENGE_STATUS.COMPLETED: - const statusText = getStatusText ? getStatusText(status) : status - return () + case CHALLENGE_STATUS.CANCELLED: + const statusText = getStatusText ? getStatusText(statusMessage) : statusMessage + return () default: - return ({statusText}) + return ({status}) } } -const renderLastUpdated = (challenge) => { - return ( - -
{formatDate(challenge.updated)}
-
{challenge.updatedBy}
- - ) -} - class ChallengeCard extends React.Component { constructor (props) { super(props) @@ -195,7 +119,8 @@ class ChallengeCard extends React.Component { isDeleteLaunch: false, isSaving: false, isCheckChalengePermission: false, - hasEditChallengePermission: false + hasEditChallengePermission: false, + loginUserRoleInProject: '' } this.onUpdateConfirm = this.onUpdateConfirm.bind(this) this.onUpdateLaunch = this.onUpdateLaunch.bind(this) @@ -278,11 +203,13 @@ class ChallengeCard extends React.Component { render () { const { isLaunch, isConfirm, isSaving, isDeleteLaunch, isCheckChalengePermission, hasEditChallengePermission } = this.state - const { challenge, shouldShowCurrentPhase, reloadChallengeList, isBillingAccountExpired, disableHover, getStatusText } = this.props - const { phaseMessage, endTime } = getPhaseInfo(challenge) + const { setActiveProject, challenge, reloadChallengeList, isBillingAccountExpired, disableHover, getStatusText, challengeTypes, loginUserRoleInProject } = this.props const deleteMessage = isCheckChalengePermission ? 'Checking permissions...' : `Do you want to delete "${challenge.name}"?` + const orUrl = `${ONLINE_REVIEW_URL}/review/actions/ViewProjectDetails?pid=${challenge.legacyId}` + const communityAppUrl = `${COMMUNITY_APP_URL}/challenges/${challenge.id}` + const isReadOnly = checkReadOnlyRoles(this.props.auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ return (
@@ -318,7 +245,7 @@ class ChallengeCard extends React.Component { {isLaunch && isConfirm && ( )} - +
+ +
+ + setActiveProject(parseInt(challenge.projectId))}>
- {challenge.name} - - {`Created by ${challenge.createdBy} at ${formatDate(challenge.created)}`} + {challenge.name}
- {renderLastUpdated(challenge)} - +
+ {formatDate(challenge.startDate)} +
+
+ {formatDate(challenge.endDate)} +
+
+ {challenge.numOfRegistrants} +
+
+ {challenge.numOfSubmissions} +
+
{renderStatus(challenge.status.toUpperCase(), getStatusText)} - - {shouldShowCurrentPhase && ( - {phaseMessage} - {endTime} - )} -
- {(disableHover ? View Challenge : hoverComponents(challenge, this.onUpdateLaunch, this.deleteModalLaunch))}
-
-
- - {challenge.numOfRegistrants || 0} -
-
- - {challenge.numOfSubmissions || 0} -
+ { + !isReadOnly && ( +
+ {(disableHover ? Edit : hoverComponents(challenge, this.onUpdateLaunch, this.deleteModalLaunch))} +
+ ) + } +
+ OR +
+
+ CA
) @@ -362,19 +298,24 @@ class ChallengeCard extends React.Component { } ChallengeCard.defaultPrps = { - shouldShowCurrentPhase: true, - reloadChallengeList: () => { } + reloadChallengeList: () => { }, + challengeTypes: [], + setActiveProject: () => {}, + loginUserRoleInProject: '' } ChallengeCard.propTypes = { challenge: PropTypes.object, - shouldShowCurrentPhase: PropTypes.bool, reloadChallengeList: PropTypes.func, partiallyUpdateChallengeDetails: PropTypes.func.isRequired, + setActiveProject: PropTypes.func, deleteChallenge: PropTypes.func.isRequired, isBillingAccountExpired: PropTypes.bool, disableHover: PropTypes.bool, - getStatusText: PropTypes.func + getStatusText: PropTypes.func, + challengeTypes: PropTypes.arrayOf(PropTypes.shape()), + auth: PropTypes.object.isRequired, + loginUserRoleInProject: PropTypes.string } export default withRouter(ChallengeCard) diff --git a/src/components/ChallengesComponent/ChallengeList/ChallengeList.module.scss b/src/components/ChallengesComponent/ChallengeList/ChallengeList.module.scss index 9f6755ba..05ac53a5 100644 --- a/src/components/ChallengesComponent/ChallengeList/ChallengeList.module.scss +++ b/src/components/ChallengesComponent/ChallengeList/ChallengeList.module.scss @@ -7,6 +7,20 @@ justify-content: space-between; } +.col-6 { + flex: 0 0 50%; + max-width: 50%; +} + +.col-9 { + flex: 0 0 75%; + max-width: 75%; +} + +.dashboardRow { + align-items: flex-end; +} + .row { display: flex; justify-content: flex-end; @@ -35,6 +49,57 @@ .inactive { color: #BE405E; } + + .field { + @include upto-sm { + display: block; + padding-bottom: 10px; + } + + label { + @include roboto-bold(); + + font-size: 16px; + line-height: 19px; + font-weight: 500; + color: $tc-gray-80; + } + + &.input1 { + max-width: 185px; + min-width: 185px; + margin-right: 14px; + white-space: nowrap; + display: flex; + align-items: center; + flex-grow: 1; + + span { + color: $tc-red; + } + } + + &.input2.error { + color: $tc-red; + margin-top: -25px; + } + &.input2 { + align-self: flex-end; + width: 50%; + margin-bottom: auto; + margin-top: auto; + display: flex; + flex-direction: row; + max-width: 500px; + min-width: 500px; + } + + &.manageLink { + margin: 2px 12px; + text-decoration: none; + font-size: 12px; + } + } } .header { @@ -43,34 +108,69 @@ display: flex; justify-content: flex-start; padding: 0 20px; + font-size: 14px; + + gap: 30px; + + .sortable { + display: flex; + cursor: pointer; + } .col1 { display: flex; - justify-content: flex-start; - align-items: center; - flex: 6; + flex: 1; + flex-direction: column; + justify-content: center; } .col2 { display: flex; - justify-content: center; - align-items: center; flex: 2; + flex-direction: column; + justify-content: center; } .col3 { display: flex; - justify-content: flex-start; - align-items: center; - flex: 2; + flex: 1; + flex-direction: column; + justify-content: center; } .col4 { display: flex; - justify-content: flex-start; - align-items: center; - flex: 2; + width: 30px; + flex-direction: column; + justify-content: center; + } + + .col5 { + display: flex; + width: 80px; + flex-direction: column; + justify-content: center; } + + .col6 { + display: flex; + width: 40px; + flex-direction: column; + justify-content: center; + } +} + +.challengeInput { + width: 60% !important; + max-width: 230px !important; +} + +.searchInputWrapper { + display: flex; +} + +.resetFilter { + margin-left: 35px; } @-moz-document url-prefix() { @@ -116,22 +216,31 @@ .challengeItem { list-style: none; - min-height: 83px; width: 100%; border-top: 1px $gray solid; padding: 10px 0; + font-size: 14px; a { text-decoration: none; } } -.paginationContainer { +.footer { display: flex; justify-content: flex-end; margin-top: 30px; } +.perPageContainer { + margin-right: 20px; + max-width: 150px; +} + +.paginationContainer { + display: flex; +} + .modalContainer { padding: 0; position: fixed; @@ -250,3 +359,26 @@ } } } + +.sortIcon { + width: 14px; + height: 12px; + margin-left: 5px; + margin-top: 2px; + height: 100%; + + &.asc { + transform: rotate(180deg); + } +} + +.filterItem { + display: flex; +} + +.to { + margin: 0 10px; + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/src/components/ChallengesComponent/ChallengeList/index.js b/src/components/ChallengesComponent/ChallengeList/index.js index 5feec134..ceb592ad 100644 --- a/src/components/ChallengesComponent/ChallengeList/index.js +++ b/src/components/ChallengesComponent/ChallengeList/index.js @@ -1,29 +1,43 @@ /** * Component to render list of challenges */ -import { debounce, map } from 'lodash' +import _, { debounce, map } from 'lodash' import React, { Component } from 'react' import PropTypes from 'prop-types' import { DebounceInput } from 'react-debounce-input' -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faFile, faUser } from '@fortawesome/free-solid-svg-icons' +import isAfter from 'date-fns/isAfter' +import DateTime from '@nateradebaugh/react-datetime' import Pagination from 'react-js-pagination' import cn from 'classnames' -import { PrimaryButton } from '../../Buttons' +import { OutlineButton, PrimaryButton } from '../../Buttons' import Modal from '../../Modal' import 'react-tabs/style/react-tabs.css' import styles from './ChallengeList.module.scss' import NoChallenge from '../NoChallenge' import ChallengeCard from '../ChallengeCard' import Message from '../Message' +import SortIcon from '../../../assets/images/sort-icon.svg' +import Select from '../../Select' +import Loader from '../../Loader' -import { - CHALLENGE_STATUS -} from '../../../config/constants' -import { checkAdmin } from '../../../util/tc' +import { CHALLENGE_STATUS, PAGE_SIZE, PAGINATION_PER_PAGE_OPTIONS, PROJECT_ROLES } from '../../../config/constants' +import { checkAdmin, checkReadOnlyRoles } from '../../../util/tc' require('bootstrap/scss/bootstrap.scss') +const defaultSearchParam = { + searchText: '', + challengeProjectOption: null, + challengeStatus: '', + challengeType: null, + sortBy: 'startDate', + sortOrder: 'desc', + challengeDate: {} +} + const theme = { container: styles.modalContainer } @@ -31,16 +45,28 @@ const theme = { class ChallengeList extends Component { constructor (props) { super(props) + + const defaultSearchParamClone = _.cloneDeep(defaultSearchParam) this.state = { - searchText: this.props.filterChallengeName, - errorMessage: null + searchText: this.props.filterChallengeName || defaultSearchParamClone.searchText, + errorMessage: null, + sortBy: this.props.filterSortBy || defaultSearchParamClone.sortBy, + sortOrder: this.props.filterSortOrder || defaultSearchParamClone.sortOrder, + challengeProjectOption: this.props.filterProjectOption || defaultSearchParamClone.challengeProjectOption, + challengeStatus: this.props.status || defaultSearchParamClone.challengeStatus, + challengeType: this.props.filterChallengeType || defaultSearchParamClone.challengeType, + challengeDate: this.props.filterDate || defaultSearchParamClone.challengeDate } this.directUpdateSearchParam = this.updateSearchParam.bind(this) // update search param without debounce this.handlePageChange = this.handlePageChange.bind(this) // update search param without debounce + this.handlePerPageChange = this.handlePerPageChange.bind(this) this.showError = this.showError.bind(this) this.hideError = this.hideError.bind(this) this.reloadChallengeList = this.reloadChallengeList.bind(this) this.updateSearchParam = debounce(this.updateSearchParam.bind(this), 1000) + this.updateSort = this.updateSort.bind(this) + this.update = debounce(this.updateSearchParam.bind(this), 1000) + this.resetFilter = this.resetFilter.bind(this) } /** @@ -48,24 +74,127 @@ class ChallengeList extends Component { * @param {String} searchText search text * @param {String} projectStatus project status */ - updateSearchParam (searchText, projectStatus) { - const { status, filterChallengeName, loadChallengesByPage, activeProjectId, selfService } = this.props - this.setState({ searchText }, () => { - if (status !== projectStatus || searchText !== filterChallengeName) { - loadChallengesByPage(1, activeProjectId, projectStatus, searchText, selfService, this.getHandle()) + updateSearchParam ( + searchText, + projectStatus, + challengeType = {}, + challengeDate = {}, + projectOption = {} + ) { + const { + dashboard, + status, + filterChallengeName, + filterChallengeType, + filterProjectOption, + filterDate, + loadChallengesByPage, + activeProjectId, + selfService + } = this.props + let projectId = dashboard ? projectOption : activeProjectId + this.setState( + { + searchText, + challengeProjectOption: projectOption, + challengeStatus: projectStatus, + challengeType, + challengeDate + }, + () => { + if ( + status !== projectStatus || + searchText !== filterChallengeName || + (projectOption || {}).value !== (filterProjectOption || {}).value || + (challengeType || {}).value !== (filterChallengeType || {}).value || + !_.isEqual(filterDate, challengeDate) + ) { + loadChallengesByPage( + 1, + projectId, + !projectStatus ? 'all' : projectStatus, + searchText, + selfService, + this.getHandle(), + challengeType, + challengeDate + ) + } } - }) + ) } /** * Update filter for getting project by pagination - * @param {Number} pageNumber page numer + * @param {Number} pageNumber page number */ handlePageChange (pageNumber) { - const { searchText } = this.state - const { page, loadChallengesByPage, activeProjectId, status, selfService } = this.props + const { searchText, sortBy, sortOrder } = this.state + const { + page, + perPage, + loadChallengesByPage, + activeProjectId, + dashboard, + filterProjectOption, + status, + selfService, + filterChallengeType, + filterDate + } = this.props + + let projectId = dashboard ? filterProjectOption : activeProjectId if (page !== pageNumber) { - loadChallengesByPage(pageNumber, activeProjectId, status, searchText, selfService, this.getHandle()) + loadChallengesByPage( + pageNumber, + projectId, + status, + searchText, + selfService, + this.getHandle(), + filterChallengeType, + filterDate, + sortBy, + sortOrder, + perPage + ) + } + } + + /** + * Update filter for getting project by pagination + * @param {Number} perPageNumber per page number + */ + handlePerPageChange (option) { + const perPageNumber = option.value + const { searchText, sortBy, sortOrder } = this.state + const { + perPage, + loadChallengesByPage, + activeProjectId, + dashboard, + filterProjectOption, + status, + selfService, + filterChallengeType, + filterDate + } = this.props + + let projectId = dashboard ? filterProjectOption : activeProjectId + if (perPage !== perPageNumber) { + loadChallengesByPage( + 1, + projectId, + status, + searchText, + selfService, + this.getHandle(), + filterChallengeType, + filterDate, + sortBy, + sortOrder, + perPageNumber + ) } } @@ -74,8 +203,21 @@ class ChallengeList extends Component { */ reloadChallengeList () { const { searchText } = this.state - const { page, loadChallengesByPage, activeProjectId, status, selfService } = this.props - loadChallengesByPage(page, activeProjectId, status, searchText, selfService, this.getHandle()) + const { + page, + loadChallengesByPage, + activeProjectId, + status, + selfService + } = this.props + loadChallengesByPage( + page, + activeProjectId, + status, + searchText, + selfService, + this.getHandle() + ) } /** @@ -95,7 +237,7 @@ class ChallengeList extends Component { getStatusTextFunc (selfService) { const draftText = selfService ? 'Waiting for approval' : 'Draft' - return (status) => { + return status => { switch (status) { case CHALLENGE_STATUS.DRAFT: return draftText @@ -109,32 +251,147 @@ class ChallengeList extends Component { if (checkAdmin(this.props.auth.token)) { return null } - return this.props.auth && this.props.auth.user ? this.props.auth.user.handle : null + return this.props.auth && this.props.auth.user + ? this.props.auth.user.handle + : null + } + + /** + * Hide error message + */ + updateSort (name) { + const { + searchText, + challengeType, + sortBy, + sortOrder, + challengeDate + } = this.state + const { + page, + activeProjectId, + status, + dashboard, + filterProjectOption, + selfService, + loadChallengesByPage + } = this.props + let order = sortOrder === 'asc' ? 'desc' : 'asc' + + if (sortBy !== name) { + order = 'desc' + } + + let projectId = dashboard ? filterProjectOption : activeProjectId + loadChallengesByPage( + page, + projectId, + status, + searchText, + selfService, + this.getHandle(), + challengeType, + challengeDate, + name, + order + ) + + this.setState({ + sortBy: name, + sortOrder: order + }) + } + + renderSortIcon (currentSortBy) { + const { sortBy, sortOrder } = this.state + return sortBy === currentSortBy ? ( + + ) : null + } + + resetFilter () { + const { + activeProjectId, + dashboard, + filterProjectOption, + selfService, + loadChallengesByPage + } = this.props + + this.setState(_.cloneDeep(defaultSearchParam)) + + let projectId = dashboard ? filterProjectOption : activeProjectId + + loadChallengesByPage( + 1, + projectId, + 'all', + '', + selfService, + this.getHandle(), + null, + {}, + null, + null, + PAGE_SIZE + ) } render () { - const { searchText, errorMessage } = this.state + const { + searchText, + errorMessage, + challengeProjectOption, + challengeStatus, + challengeType, + challengeDate + } = this.state + const { activeProject, warnMessage, challenges, status, page, + projects, + dashboard, perPage, + isLoading, totalChallenges, partiallyUpdateChallengeDetails, deleteChallenge, isBillingAccountExpired, + setActiveProject, billingStartDate, billingEndDate, isBillingAccountLoadingFailed, isBillingAccountLoading, - selfService + selfService, + challengeTypes, + loginUserRoleInProject } = this.props + const isReadOnly = checkReadOnlyRoles(this.props.auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ + if (warnMessage) { return } + const statusOptions = _.map(CHALLENGE_STATUS, item => ({ + label: _.capitalize(item), + value: _.capitalize(item) + })) + + const challengeTypesOptions = challengeTypes.map(item => ({ + label: item.name, + value: item.abbreviation + })) + let selectedTab = 0 switch (status) { case CHALLENGE_STATUS.APPROVED: @@ -156,149 +413,415 @@ class ChallengeList extends Component { let warningModal = null if (errorMessage) { - warningModal = -
-
Error
- {errorMessage} -
-
- + warningModal = ( + +
+
Error
+ {errorMessage} +
+
+ +
-
- + + ) + } + let projectOptions + let projectOption + if (dashboard) { + projectOptions = projects.map(p => { + return { + label: p.name, + value: p.id + } + }) + projectOptions.unshift({ + label: 'All Projects', + value: -1 + }) + + let projectId = (challengeProjectOption && challengeProjectOption.value) || -1 + projectOption = projectOptions.find(p => p.value === projectId) } return (
-
- {!isBillingAccountLoading && !isBillingAccountLoadingFailed && !isBillingAccountExpired && ( -
- Billing Account: ACTIVE   Start Date: {billingStartDate}   End Date: {billingEndDate} + {dashboard &&

My Challenges

} +
+ {!dashboard && + !isBillingAccountLoading && + !isBillingAccountLoadingFailed && + !isBillingAccountExpired && ( +
+ Billing Account: + ACTIVE  {' '} + Start Date:{' '} + {billingStartDate}  {' '} + End Date: {billingEndDate}
)} - {!isBillingAccountLoading && !isBillingAccountLoadingFailed && isBillingAccountExpired && ( -
- Billing Account: INACTIVE   Start Date: {billingStartDate}   End Date: {billingEndDate} + {!dashboard && + !isBillingAccountLoading && + !isBillingAccountLoadingFailed && + isBillingAccountExpired && ( +
+ Billing Account: + INACTIVE  {' '} + Start Date:{' '} + {billingStartDate}  {' '} + End Date: {billingEndDate}
)} - {!isBillingAccountLoading && isBillingAccountLoadingFailed && ( -
Billing Account failed to load
+ {!dashboard && + !isBillingAccountLoading && + isBillingAccountLoadingFailed && ( +
+ + Billing Account failed to load + +
)} -
- this.updateSearchParam(e.target.value, status)} - value={searchText} - /> + {dashboard && ( +
+
+ +
+
+ + this.updateSearchParam( + searchText, + e ? e.value : null, + challengeType, + challengeDate, + projectOption ) - }) - } - - ) + } + isClearable + /> +
+
+
+
+ +
+
+ + this.updateSearchParam( + searchText, + status, + challengeType, + { + ...challengeDate, + startDateStart: e + }, + projectOption + ) + } + /> + + { + return isAfter(current, challengeDate.startDateStart) + }} + onChange={e => + this.updateSearchParam( + searchText, + status, + challengeType, + { + ...challengeDate, + startDateEnd: e + }, + projectOption + ) + } + /> +
+
+
+
+
+
+ +
+
+ +
+
+ +
{warningModal}
@@ -307,19 +830,28 @@ class ChallengeList extends Component { } ChallengeList.defaultProps = { + isLoading: false, + loginUserRoleInProject: '' } ChallengeList.propTypes = { challenges: PropTypes.arrayOf(PropTypes.object), + projects: PropTypes.arrayOf(PropTypes.object), activeProject: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string }), warnMessage: PropTypes.string, filterChallengeName: PropTypes.string, + filterChallengeType: PropTypes.shape(), + filterDate: PropTypes.shape(), + filterSortBy: PropTypes.string, + filterSortOrder: PropTypes.string, status: PropTypes.string, activeProjectId: PropTypes.number, + filterProjectOption: PropTypes.shape(), loadChallengesByPage: PropTypes.func.isRequired, + setActiveProject: PropTypes.func.isRequired, page: PropTypes.number.isRequired, perPage: PropTypes.number.isRequired, totalChallenges: PropTypes.number.isRequired, @@ -327,11 +859,15 @@ ChallengeList.propTypes = { deleteChallenge: PropTypes.func.isRequired, isBillingAccountExpired: PropTypes.bool, billingStartDate: PropTypes.string, + isLoading: PropTypes.bool, billingEndDate: PropTypes.string, isBillingAccountLoadingFailed: PropTypes.bool, isBillingAccountLoading: PropTypes.bool, + dashboard: PropTypes.bool, selfService: PropTypes.bool, - auth: PropTypes.object.isRequired + auth: PropTypes.object.isRequired, + challengeTypes: PropTypes.arrayOf(PropTypes.shape()).isRequired, + loginUserRoleInProject: PropTypes.string } export default ChallengeList diff --git a/src/components/ChallengesComponent/ChallengeStatus/ChallengeStatus.module.scss b/src/components/ChallengesComponent/ChallengeStatus/ChallengeStatus.module.scss index 12ac0156..5d29d46c 100644 --- a/src/components/ChallengesComponent/ChallengeStatus/ChallengeStatus.module.scss +++ b/src/components/ChallengesComponent/ChallengeStatus/ChallengeStatus.module.scss @@ -37,5 +37,9 @@ &.yellow { background-color: $status-yellow; } + + &.red { + background-color: $tc-red; + } } diff --git a/src/components/ChallengesComponent/ChallengeStatus/index.js b/src/components/ChallengesComponent/ChallengeStatus/index.js index a5d074ea..4290670f 100644 --- a/src/components/ChallengesComponent/ChallengeStatus/index.js +++ b/src/components/ChallengesComponent/ChallengeStatus/index.js @@ -15,7 +15,8 @@ const statuses = { [CHALLENGE_STATUS.APPROVED]: styles.yellow, [CHALLENGE_STATUS.NEW]: styles.yellow, [CHALLENGE_STATUS.DRAFT]: styles.gray, - [CHALLENGE_STATUS.COMPLETED]: styles.blue + [CHALLENGE_STATUS.COMPLETED]: styles.blue, + [CHALLENGE_STATUS.CANCELLED]: styles.red } const ChallengeStatus = ({ status, statusText }) => { diff --git a/src/components/ChallengesComponent/ChallengeTag/ChallengeTag.module.scss b/src/components/ChallengesComponent/ChallengeTag/ChallengeTag.module.scss index 5b1a629e..d7c38e7a 100644 --- a/src/components/ChallengesComponent/ChallengeTag/ChallengeTag.module.scss +++ b/src/components/ChallengesComponent/ChallengeTag/ChallengeTag.module.scss @@ -1,40 +1,69 @@ @import '../../../styles/includes'; -.tag { - display: inline-block; - padding: 0 15px; - border-radius: 3px; - background-color: $lighter-gray; - min-width: 54px; - text-align: center; - max-height: 22px; - height: 22px; - margin: 5px 0; - color: $dark-gray; - - span { - font-size: 15px; - line-height: 22px; - vertical-align: top; +$base-unit: 5px; +$track-space-10: $base-unit * 2; +$track-space-15: $base-unit * 3; +$track-space-20: $base-unit * 4; +$track-code-pad: ($base-unit * 2) - 2; +$corner-radius: 2px; + +.trackIcon { + display: flex; + width: $base-unit * 6 + 2; + height: $base-unit * 6 + 2; + margin-right: $track-space-20; + flex-direction: column; + + .mainIcon { + @include tc-label-md; + + text-align: center; + line-height: $track-space-15; + color: $white; + padding: $track-code-pad - 1 0 $track-code-pad; + border-radius: 4px; + width: 100%; + height: 100%; + + &.CH { + background: $tc-green-40; + } + + &.F2F { + background: $tc-blue-30; + } + + &.TSK { + background: $tc-turquose-30; + } + + &.MM { + background: $track-code-red; + } + + &.RDM { + background: $track-code-purple; + } + + &.SKL { + background: $track-code-purplish; + } + + &.SRM { + background: $track-code-yellow; + } + + &.PC { + background: $track-code-grey; + } + + &.MA { + background: $track-code-grey; + } + + &.withTco { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } } -} - -.dataScience { - color: $white; - background-color: $orange; -} - -.development { - color: $white; - background-color: $green; -} - -.qa { - color: $white; - background-color: $green; -} - -.design { - color: $white; - background-color: $light-blue; -} +} \ No newline at end of file diff --git a/src/components/ChallengesComponent/ChallengeTag/index.js b/src/components/ChallengesComponent/ChallengeTag/index.js index e5d73e5f..2bc95c8d 100644 --- a/src/components/ChallengesComponent/ChallengeTag/index.js +++ b/src/components/ChallengesComponent/ChallengeTag/index.js @@ -1,30 +1,33 @@ import React from 'react' import PropTypes from 'prop-types' -import cn from 'classnames' -import styles from './ChallengeTag.module.scss' - -import { CHALLENGE_TRACKS } from '../../../config/constants' -const ChallengeTag = ({ track, challengeType }) => { - const className = cn(styles.tag, { - [styles.dataScience]: track === CHALLENGE_TRACKS.DATA_SCIENCE, - [styles.development]: track === CHALLENGE_TRACKS.DEVELOP, - [styles.design]: track === CHALLENGE_TRACKS.DESIGN, - [styles.qa]: track === CHALLENGE_TRACKS.QA - }) +import styles from './ChallengeTag.module.scss' +import { getChallengeTypeAbbr } from '../../../util/tc' +export default function ChallengeTag ({ + type, challengeTypes +}) { + let abbreviation = getChallengeTypeAbbr(type, challengeTypes) + if (['CH', 'F2F', 'TSK', 'MM', 'RDM', 'SKL', 'MA', 'SRM', 'PC'].indexOf(abbreviation) < 0) { + abbreviation = '' + } return ( -
-
- {challengeType} + +
+ {abbreviation === 'PC' ? 'P' : abbreviation}
-
+ ) } -ChallengeTag.propTypes = { - track: PropTypes.string, - challengeType: PropTypes.string +ChallengeTag.defaultProps = { + type: 'Development', + challengeTypes: [] } -export default ChallengeTag +ChallengeTag.propTypes = { + type: PropTypes.string, + challengeTypes: PropTypes.arrayOf(PropTypes.shape()) +} diff --git a/src/components/ChallengesComponent/ChallengesComponent.module.scss b/src/components/ChallengesComponent/ChallengesComponent.module.scss index c779d15b..291401c1 100644 --- a/src/components/ChallengesComponent/ChallengesComponent.module.scss +++ b/src/components/ChallengesComponent/ChallengesComponent.module.scss @@ -3,7 +3,7 @@ .challenges { width: 100%; box-sizing: border-box; - padding: 30px; + padding: 20px 30px 30px 30px; } .title { @@ -11,27 +11,32 @@ font-size: 24px; font-weight: 700; - margin-right: 5px; + margin-right: 8px; line-height: 29px; color: $challenges-title; - text-align: center; + text-align: left; } .titleContainer { margin-top: 30px; display: flex; justify-content: space-between; - align-items: center; - padding: 0 20px; + padding: 0 30px; } .titleLinks { - align-items: center; + align-items: flex-start; display: flex; + span { + min-width: 14%; + margin-top: 3px; + } + > span a { margin: 2px 5px 0; + min-width: 24%; } > a + a { @@ -40,7 +45,7 @@ } .buttonLaunchNew { - min-width: 135px; + min-width: 169px; height: 40px; text-decoration: none; diff --git a/src/components/ChallengesComponent/index.js b/src/components/ChallengesComponent/index.js index 291376db..6fa3adc0 100644 --- a/src/components/ChallengesComponent/index.js +++ b/src/components/ChallengesComponent/index.js @@ -1,24 +1,32 @@ /** * Component to render Challenges page */ -import React from 'react' +import React, { useState, useEffect } from 'react' +import _ from 'lodash' import PropTypes from 'prop-types' -import Sticky from 'react-stickynode' import { Helmet } from 'react-helmet' import { Link } from 'react-router-dom' -import { CONNECT_APP_URL } from '../../config/constants' +import { CONNECT_APP_URL, PROJECT_ROLES } from '../../config/constants' import { PrimaryButton } from '../Buttons' import ChallengeList from './ChallengeList' import styles from './ChallengesComponent.module.scss' -import Loader from '../Loader' import xss from 'xss' +import { checkReadOnlyRoles } from '../../util/tc' const ChallengesComponent = ({ challenges, + projects, isLoading, + setActiveProject, warnMessage, filterChallengeName, + filterChallengeType, + filterDate, + filterSortBy, + filterSortOrder, activeProject, + filterProjectOption, + dashboard, status, loadChallengesByPage, activeProjectId, @@ -33,71 +41,98 @@ const ChallengesComponent = ({ isBillingAccountLoadingFailed, isBillingAccountLoading, selfService, - auth + auth, + challengeTypes }) => { + const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') + const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ + + useEffect(() => { + const loggedInUser = auth.user + const projectMembers = activeProject.members + const loginUserProjectInfo = _.find(projectMembers, { userId: loggedInUser.userId }) + if (loginUserProjectInfo && loginUserRoleInProject !== loginUserProjectInfo.role) { + setLoginUserRoleInProject(loginUserProjectInfo.role) + } + }, [activeProject, auth]) + return ( - -
- -
-
-
- {activeProject && activeProject.id && ( - - (View Project) - - )} -
- {(activeProject && activeProject.id) ? ( - - - - ) : ( - - )} -
-
- {isLoading ? ( - - ) : ( - +
+ + {!dashboard &&
+
+
+ {activeProject && activeProject.id && ( + + ( + + View Project + + ) + )}
+ {activeProject && activeProject.id && !isReadOnly ? ( + + + + ) : ( + + )} +
} +
+
- +
) } ChallengesComponent.propTypes = { challenges: PropTypes.arrayOf(PropTypes.object), + projects: PropTypes.arrayOf(PropTypes.object), activeProject: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string @@ -105,6 +140,11 @@ ChallengesComponent.propTypes = { isLoading: PropTypes.bool, warnMessage: PropTypes.string, filterChallengeName: PropTypes.string, + filterChallengeType: PropTypes.shape(), + filterProjectOption: PropTypes.shape(), + filterDate: PropTypes.shape(), + filterSortBy: PropTypes.string, + filterSortOrder: PropTypes.string, status: PropTypes.string, activeProjectId: PropTypes.number, loadChallengesByPage: PropTypes.func.isRequired, @@ -113,18 +153,22 @@ ChallengesComponent.propTypes = { totalChallenges: PropTypes.number.isRequired, partiallyUpdateChallengeDetails: PropTypes.func.isRequired, deleteChallenge: PropTypes.func.isRequired, + setActiveProject: PropTypes.func.isRequired, isBillingAccountExpired: PropTypes.bool, + dashboard: PropTypes.bool, billingStartDate: PropTypes.string, billingEndDate: PropTypes.string, isBillingAccountLoadingFailed: PropTypes.bool, isBillingAccountLoading: PropTypes.bool, selfService: PropTypes.bool, - auth: PropTypes.object.isRequired + auth: PropTypes.object.isRequired, + challengeTypes: PropTypes.arrayOf(PropTypes.shape()) } ChallengesComponent.defaultProps = { challenges: [], - isLoading: true + isLoading: true, + challengeTypes: [] } export default ChallengesComponent diff --git a/src/components/Handle/Handle.module.scss b/src/components/Handle/Handle.module.scss index 6441d9c7..39870917 100644 --- a/src/components/Handle/Handle.module.scss +++ b/src/components/Handle/Handle.module.scss @@ -3,6 +3,6 @@ .handle { @include roboto-medium; display: inline-block; - color: $text-color; text-decoration: none; + color: $white; } diff --git a/src/components/Modal/AlertModal.js b/src/components/Modal/AlertModal.js index a603a51f..084263c2 100644 --- a/src/components/Modal/AlertModal.js +++ b/src/components/Modal/AlertModal.js @@ -6,7 +6,7 @@ import styles from './ConfirmationModal.module.scss' import OutlineButton from '../Buttons/OutlineButton' import PrimaryButton from '../Buttons/PrimaryButton' -const AlertModal = ({ title, message, theme, onClose, closeLink, okLink, closeText, okText }) => ( +const AlertModal = ({ title, message, theme, onClose, closeLink, okLink, closeText, okText, onOk }) => (
{title}
@@ -28,6 +28,7 @@ const AlertModal = ({ title, message, theme, onClose, closeLink, okLink, closeTe text={okText} type={'success'} link={okLink} + onClick={okLink ? () => {} : onOk} />
)} @@ -41,6 +42,7 @@ AlertModal.propTypes = { message: PropTypes.string, theme: PropTypes.shape(), onClose: PropTypes.func, + onOk: PropTypes.func, closeText: PropTypes.string, closeLink: PropTypes.string, okText: PropTypes.string, diff --git a/src/components/Modal/ConfirmationModal.module.scss b/src/components/Modal/ConfirmationModal.module.scss index 38d46ea2..0fc48a82 100644 --- a/src/components/Modal/ConfirmationModal.module.scss +++ b/src/components/Modal/ConfirmationModal.module.scss @@ -58,9 +58,10 @@ width: 193px; height: 40px; margin-right: 33px; - + span { - font-size: 18px; + font-size: 16px; + line-height: 1; font-weight: 500; } } diff --git a/src/components/PhaseInput/index.js b/src/components/PhaseInput/index.js index aeaa8619..7eb3c48b 100644 --- a/src/components/PhaseInput/index.js +++ b/src/components/PhaseInput/index.js @@ -1,5 +1,5 @@ import moment from 'moment' -import React from 'react' +import React, { useEffect } from 'react' import PropTypes from 'prop-types' import styles from './PhaseInput.module.scss' import cn from 'classnames' @@ -31,6 +31,18 @@ const PhaseInput = ({ onUpdatePhase, phase, readOnly, phaseIndex }) => { }) } + useEffect(() => { + if (!startDate && onUpdatePhase) { + let startDate = moment().format(dateFormat) + let endDate = getEndDate(startDate, duration) + onUpdatePhase({ + startDate, + endDate, + duration + }) + } + }, [startDate]) + const onDurationChange = (e, isBlur = false) => { if (e.length > MAX_LENGTH) return null diff --git a/src/components/Tab/Tab.module.scss b/src/components/Tab/Tab.module.scss new file mode 100644 index 00000000..f8af09a0 --- /dev/null +++ b/src/components/Tab/Tab.module.scss @@ -0,0 +1,145 @@ +@import "../../styles/includes"; + +.tabs { + margin: 0 20px; + + > h1 { + @include roboto-bold(); + + font-weight: 400; + color: #2a2a2a; + font-size: 32px; + margin: 32px 0 24px; + line-height: 32px; + font-weight: 600; + text-transform: uppercase; + } + + hr { + border: none; + height: 1px; + background-color: #e5e5e5; + margin: 0; + } +} + +.mobileTabExpanded { + margin: 0 16px; + background-color: #eaf6fd; + + .item { + height: 40px; + margin: 0; + + p { + // @include barlow-bold; + + font-weight: 600; + color: #555; + font-size: 14px; + line-height: 20px; + text-transform: uppercase; + padding-left: 16px; + padding-top: 10px; + } + } + + .active { + background-color: #bae1f9; + + p { + color: #2a2a2a; + font-weight: 700; + } + } +} + +.challengeTab { + display: flex; + height: 42px; + padding: 0px; + margin-top: 24px; + background-color: #eaf6fd; + border-radius: 4px 4px 0 0; + border-bottom: 1px solid #d4d4d4; + position: relative; + + .item { + // @include barlow; + + font-weight: 600; + font-size: 14px; + color: #555; + line-height: 20px; + padding: 12px 16px 10px 16px; + cursor: pointer; + display: flex; + justify-content: center; + + &:not(.active):hover { + background-color: #bae1f9; + } + } + + .active { + color: #0c0c0c; + font-weight: 700; + + &::after { + content: ""; + background-image: url(../../assets/images/nav-active-item.svg); + height: 10px; + width: 40px; + margin-top: 10px; + justify-content: center; + z-index: 100; + display: block; + position: absolute; + top: 31px; + } + } +} + +.mobileTabContainer { + background-color: #eaf6fd; + height: 40px; + margin-top: 32px; + display: flex; + justify-content: space-between; + border-radius: 4px 4px 0 0; + border-bottom: 1px solid #d4d4d4; + + .title { + // @include barlow-bold; + + font-weight: 700; + color: #0c0c0c; + font-size: 14px; + line-height: 20px; + text-transform: uppercase; + padding-left: 16px; + padding-top: 10px; + } + + .icon { + width: 16px; + height: 9px; + margin-right: 15px; + display: flex; + flex-direction: column; + justify-content: center; + align-self: center; + cursor: pointer; + transform: scale(1.5); + } + + .down { + transform: scale(1.5) rotate(180deg); + margin-right: 20px !important; + } + + + @media (max-width: 1024px) { + margin: 0 16px; + } +} diff --git a/src/components/Tab/index.js b/src/components/Tab/index.js new file mode 100644 index 00000000..01649259 --- /dev/null +++ b/src/components/Tab/index.js @@ -0,0 +1,94 @@ +import React from 'react' +import cn from 'classnames' +import PT from 'prop-types' +import styles from './Tab.module.scss' + +const Tab = ({ + currentTab, + selectTab +}) => { + const onActiveClick = () => { + if (currentTab === 1) { + return + } + selectTab(1) + } + + const onPastChallengesClick = () => { + if (currentTab === 2) { + return + } + selectTab(2) + } + + const onUsersClick = () => { + if (currentTab === 3) { + return + } + selectTab(3) + } + + const tabComponent = ( +
    +
  • { + if (e.key !== 'Enter') { + return + } + onActiveClick() + }} + role='presentation' + > + All Work +
  • +
  • { + if (e.key !== 'Enter') { + return + } + onPastChallengesClick() + }} + role='presentation' + > + Projects +
  • +
  • { + if (e.key !== 'Enter') { + return + } + onUsersClick() + }} + role='presentation' + > + Users +
  • +
+ ) + + return ( +
+ {tabComponent} +
+ ) +} + +Tab.defaultProps = { + selectTab: () => {} +} + +Tab.propTypes = { + selectTab: PT.func.isRequired, + currentTab: PT.number.isRequired +} + +export default Tab diff --git a/src/components/TopBar/Topbar.module.scss b/src/components/TopBar/Topbar.module.scss deleted file mode 100644 index 369066d5..00000000 --- a/src/components/TopBar/Topbar.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import "../../styles/includes"; -.topbar { - height: 60px; - background-color: $white; - padding: 0 30px 0 30px; - border-bottom: 1px solid $gray; -} - -.hide-line { - border-bottom: none; -} - -.details { - display: block; - margin-left: auto; - text-align: right; - font-size: 16px; - line-height: 60px; - -} - -.icon { - vertical-align: middle; - color: $blue; - margin-left: 20px; - font-size: 20px; -} diff --git a/src/components/TopBar/index.js b/src/components/TopBar/index.js index fda398f7..ccf6137e 100644 --- a/src/components/TopBar/index.js +++ b/src/components/TopBar/index.js @@ -1,40 +1,67 @@ -/** - * Component to render top bar of app - */ -import React from 'react' +/* global tcUniNav */ +import React, { useRef, useState, useEffect } from 'react' import PropTypes from 'prop-types' -import cn from 'classnames' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faSignInAlt } from '@fortawesome/free-solid-svg-icons' -import { get } from 'lodash' -import styles from './Topbar.module.scss' -import Handle from '../Handle' -import { COMMUNITY_APP_URL } from '../../config/constants' - -const TopBar = ({ user, hideBottomLine }) => { - return ( -
- {user && ( -
- Welcome,{' '} - - - - -
- )} -
- ) +import { COMMUNITY_APP_URL, HEADER_AUTH_URLS_HREF, HEADER_AUTH_URLS_LOCATION } from '../../config/constants' + +let uniqueId = 0 + +const HEADER_AUTH_URLS = { + href: HEADER_AUTH_URLS_HREF, + location: HEADER_AUTH_URLS_LOCATION +} +const BASE = COMMUNITY_APP_URL + +const TopBar = ({ auth }) => { + const uniNavInitialized = useRef(false) + const toolNameRef = useRef('Work Manager') + const authURLs = HEADER_AUTH_URLS + const headerRef = useRef() + const [headerId, setHeaderId] = useState(0) + + useEffect(() => { + uniqueId += 1 + setHeaderId(uniqueId) + }, []) + + useEffect(() => { + if (uniNavInitialized.current || !headerId) { + return + } + + uniNavInitialized.current = true + + const regSource = window.location.pathname.split('/')[1] + const retUrl = encodeURIComponent(window.location.href) + tcUniNav('init', `headerNav-${headerId}`, { + type: 'tool', + toolName: toolNameRef.current, + toolRoot: '/', + user: 'auto', + signOut: () => { + window.location = `${BASE}/logout?ref=nav` + }, + signIn: () => { + window.location = `${authURLs.location + .replace('%S', retUrl) + .replace('member?', '#!/member?')}®Source=${regSource}` + }, + signUp: () => { + window.location = `${authURLs.location + .replace('%S', retUrl) + .replace('member?', '#!/member?')}&mode=signUp®Source=${regSource}` + } + }) + }, [headerId]) + + return
+} + +TopBar.defaultProps = { + auth: {} } TopBar.propTypes = { - user: PropTypes.object, - hideBottomLine: PropTypes.bool + auth: PropTypes.shape() } export default TopBar diff --git a/src/components/TopcoderFooter/index.js b/src/components/TopcoderFooter/index.js new file mode 100644 index 00000000..27f6c502 --- /dev/null +++ b/src/components/TopcoderFooter/index.js @@ -0,0 +1,36 @@ +/* global tcUniNav */ +import React, { useEffect, useRef, useState } from 'react' +import styles from './styles.module.scss' + +let uniqueId = 0 + +export default function TopcoderFooter () { + const footerRef = useRef() + const footerInitialized = useRef(false) + const [footerId, setFooterId] = useState(0) + + useEffect(() => { + uniqueId += 1 + setFooterId(uniqueId) + }, []) + + useEffect(() => { + if (footerInitialized.current || !footerId) { + return + } + + footerInitialized.current = true + + tcUniNav('init', `footerNav-${footerId}`, { + type: 'footer' + }) + }, [footerId]) + + return ( +
+ ) +} diff --git a/src/components/TopcoderFooter/styles.module.scss b/src/components/TopcoderFooter/styles.module.scss new file mode 100644 index 00000000..d6960ed7 --- /dev/null +++ b/src/components/TopcoderFooter/styles.module.scss @@ -0,0 +1,5 @@ +@import '../../styles/includes'; + +.container { + margin-top: auto; +} diff --git a/src/components/TwoColsLayout/TwoColsLayout.module.scss b/src/components/TwoRowsLayout/TwoRowsLayout.module.scss similarity index 53% rename from src/components/TwoColsLayout/TwoColsLayout.module.scss rename to src/components/TwoRowsLayout/TwoRowsLayout.module.scss index 21fd886c..a85ead3b 100644 --- a/src/components/TwoColsLayout/TwoColsLayout.module.scss +++ b/src/components/TwoRowsLayout/TwoRowsLayout.module.scss @@ -1,33 +1,19 @@ .container { display: flex; + flex-direction: column; &.scrollIndependent { - .sidebar, .content { - min-height: 0; - height: 100vh; - overflow: auto; + // min-height: 0; + // height: 100vh; + // overflow: auto; } } } -.sidebar { - height: 100vh; - position: relative; - width: 280px; - - @media screen and (max-width: 300px * 4) { - width: auto; - flex: 1; - } - -} - .content { - /* minus sidebar width and minus sidebar border width */ - width: calc(100% - 280px); - max-height: 100vh; + height: 100vh; display: flex; position: relative; flex-direction: column; diff --git a/src/components/TwoColsLayout/index.js b/src/components/TwoRowsLayout/index.js similarity index 51% rename from src/components/TwoColsLayout/index.js rename to src/components/TwoRowsLayout/index.js index 2c740b6d..e1df88a1 100644 --- a/src/components/TwoColsLayout/index.js +++ b/src/components/TwoRowsLayout/index.js @@ -6,9 +6,9 @@ import React from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import styles from './TwoColsLayout.module.scss' +import styles from './TwoRowsLayout.module.scss' -const TwoColsLayout = ({ +const TwoRowsLayout = ({ children, scrollIndependent }) => ( @@ -17,40 +17,28 @@ const TwoColsLayout = ({
) -TwoColsLayout.Sidebar = ({ children }) => ( - -) - -TwoColsLayout.Sidebar.defaultProps = { - children: null -} - -TwoColsLayout.Sidebar.propTypes = { - children: PropTypes.node -} - -TwoColsLayout.Content = ({ children }) => ( +TwoRowsLayout.Content = ({ children }) => (
{children}
) -TwoColsLayout.Content.defaultProps = { +TwoRowsLayout.Content.defaultProps = { children: null } -TwoColsLayout.Content.propTypes = { +TwoRowsLayout.Content.propTypes = { children: PropTypes.node } -TwoColsLayout.defaultProps = { +TwoRowsLayout.defaultProps = { children: null, scrollIndependent: false } -TwoColsLayout.propTypes = { +TwoRowsLayout.propTypes = { children: PropTypes.node, scrollIndependent: PropTypes.bool } -export default TwoColsLayout +export default TwoRowsLayout diff --git a/src/components/UserCard/UserCard.module.scss b/src/components/UserCard/UserCard.module.scss new file mode 100644 index 00000000..da47949c --- /dev/null +++ b/src/components/UserCard/UserCard.module.scss @@ -0,0 +1,216 @@ +@import "../../styles/includes"; + +.item { + display: flex; + font-size: 14px; + gap: 30px; + height: 30px; + padding: 0 20px; + + .col5 { + display: flex; + width: 150px; + flex-direction: column; + justify-content: center; + } +} + +.tcRadioButton { + .tc-radioButton-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; + margin: 0; + padding: 0; + vertical-align: bottom; + position: relative; + display: inline-block; + + input[type=radio] { + display: none; + } + + .isDisabled { + pointer-events: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 16px; + height: 16px; + border-radius: 8px; + 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: 8px; + height: 8px; + background: transparent; + top: 4px; + left: 4px; + border: 4px solid $tc-blue-20; + border-radius: 4px; + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 150px; + } + } + + input[type=radio]:checked ~ label { + background: $tc-blue-20; + } + + input[type=radio]:checked + label::after { + opacity: 1; + border-color: $white; + } +} + +.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; + } + } + } +} diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js new file mode 100644 index 00000000..318ce474 --- /dev/null +++ b/src/components/UserCard/index.js @@ -0,0 +1,184 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import styles from './UserCard.module.scss' +import { PROJECT_ROLES } from '../../config/constants' +import PrimaryButton from '../Buttons/PrimaryButton' +import AlertModal from '../Modal/AlertModal' +import { updateProjectMemberRole } from '../../services/projects' +import { wait } from '../../util/helper' +import _ from 'lodash' + +const theme = { + container: styles.modalContainer +} + +class UserCard extends Component { + constructor (props) { + super(props) + this.state = { + isUpdatingPermission: false, + showWarningModal: false, + permissionUpdateError: null, + showSuccessModal: false + } + this.updatePermission = this.updatePermission.bind(this) + this.resetPermState = this.resetPermState.bind(this) + } + + resetPermState () { + this.setState({ + isUpdatingPermission: false, + showWarningModal: false, + permissionUpdateError: null, + showSuccessModal: false + }) + } + + async updatePermission (newRole) { + if (this.state.isUpdatingPermission) { return } + + this.setState({ + isUpdatingPermission: true + }) + + const { user, reloadProjectMembers } = this.props + + try { + await updateProjectMemberRole(user.projectId, user.id, newRole) + await wait(1000) + reloadProjectMembers(user.projectId) + this.setState({ showSuccessModal: true }) + } catch (e) { + const error = _.get( + e, + 'response.data.message', + `Unable to update permission` + ) + this.setState({ showWarningModal: true, permissionUpdateError: error }) + } + } + + render () { + const { user, onRemoveClick, isEditable } = this.props + const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) + return ( +
+ { + this.state.isUpdatingPermission && ( + + ) + } + {this.state.showWarningModal && ( + + )} + {this.state.showSuccessModal && ( + + )} +
+
+ {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)} + /> + +
)} +
+ {isEditable ? (
+ { onRemoveClick(user) }} /> +
) : null} +
+
+ ) + } +} + +UserCard.propTypes = { + user: PropTypes.object, + reloadProjectMembers: PropTypes.func.isRequired, + onRemoveClick: PropTypes.func.isRequired, + isEditable: PropTypes.bool +} + +export default UserCard diff --git a/src/components/Users/Users.module.scss b/src/components/Users/Users.module.scss new file mode 100644 index 00000000..d86de92d --- /dev/null +++ b/src/components/Users/Users.module.scss @@ -0,0 +1,484 @@ +@import '../../styles/includes'; + +.list { + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.contentContainer { + margin: 0 20px; + padding-left: 15px; + + .col-6 { + padding: 0px; + } +} + +.userList { + display: flex; + flex-direction: column; + background-color: $white; + padding: 0; + margin: 0; +} + +.errorMesssage, .required { + color: $tc-red; +} + +.userItem { + list-style: none; + width: 100%; + border-top: 1px $gray solid; + padding: 10px 0; + font-size: 14px; + + a { + text-decoration: none; + } +} + + +.row { + display: flex; + margin-bottom: 16px; + align-items: center; + + input { + max-width: 280px; + + @include upto-sm { + display: block; + padding-bottom: 10px; + } + } + + .title { + font-weight: bold; + } + .error { + font-weight: bold; + color: #BE405E; + } + .active { + color: #008000; + } + .inactive { + color: #BE405E; + } + + .field { + @include upto-sm { + display: block; + padding-bottom: 10px; + } + + label { + @include roboto-bold(); + + font-size: 16px; + line-height: 19px; + font-weight: 500; + color: $tc-gray-80; + } + + &.input1 { + max-width: 185px; + min-width: 185px; + margin-right: 14px; + white-space: nowrap; + display: flex; + align-items: center; + flex-grow: 1; + + span { + color: $tc-red; + } + } + + &.input2.error { + color: $tc-red; + margin-top: -25px; + } + &.input2 { + align-self: flex-end; + width: 50%; + margin-bottom: auto; + margin-top: auto; + display: flex; + flex-direction: row; + max-width: 500px; + min-width: 500px; + } + + &.manageLink { + margin: 2px 12px; + text-decoration: none; + font-size: 12px; + } + } +} + +.header { + background-color: $light-bg; + height: 50px; + display: flex; + justify-content: flex-start; + padding: 0 20px; + font-size: 14px; + + gap: 30px; + + .sortable { + display: flex; + cursor: pointer; + } + + .col1 { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + + span { + color: $tc-red; + } + } + + .col2 { + display: flex; + flex: 2; + flex-direction: column; + justify-content: center; + } + + .col3 { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + } + + .col4 { + display: flex; + width: 30px; + flex-direction: column; + justify-content: center; + } + + .col5 { + display: flex; + width: 150px; + flex-direction: column; + justify-content: center; + } + + .col6 { + display: flex; + width: 40px; + flex-direction: column; + justify-content: center; + } +} + +.challengeInput { + width: 94% !important; + margin-left: -6px; +} + +@-moz-document url-prefix() { + .challengeInput { + &::-moz-placeholder { + /* Mozilla Firefox 19+ */ + line-height: 38px; + } + &::-webkit-input-placeholder { + /* Webkit */ + line-height: 38px; + } + &:-ms-input-placeholder { + /* IE */ + line-height: 38px; + } + } +} + +.tabsContainer { + ul { + margin: 0; + } + + :global { + .react-tabs__tab--selected { + background: $light-bg; + + &:focus::after { + background: $light-bg; + } + } + } +} + +.challengeList { + display: flex; + flex-direction: column; + background-color: $white; + padding: 0; + margin: 0; +} + +.challengeItem { + list-style: none; + width: 100%; + border-top: 1px $gray solid; + padding: 10px 0; + font-size: 14px; + + a { + text-decoration: none; + } +} + +.paginationContainer { + display: flex; + justify-content: flex-end; + margin-top: 30px; +} + +.addUserTitle { + min-width: 100px; + font-weight: 500; +} + +.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; + } + } + } +} + +.sortIcon { + width: 14px; + height: 12px; + margin-left: 5px; + margin-top: 2px; + height: 100%; + + &.asc { + transform: rotate(180deg); + } +} + +.filterItem { + display: flex; +} + +.to { + margin: 0 10px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.addButtonContainer { + width: 110px; + height: 30px; + margin-top: 20px; + margin-bottom: 20px; +} + +.addUserContentContainer { + +} + +.tcRadioButton { + .tc-radioButton-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; + + input[type=radio] { + display: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 16px; + height: 16px; + border-radius: 8px; + 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: 8px; + height: 8px; + background: transparent; + top: 4px; + left: 4px; + border: 4px solid $tc-blue-20; + border-radius: 4px; + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 150px; + } + } + + input[type=radio]:checked ~ label { + background: $tc-blue-20; + } + + input[type=radio]:checked + label::after { + opacity: 1; + border-color: $white; + } +} diff --git a/src/components/Users/index.js b/src/components/Users/index.js new file mode 100644 index 00000000..b0a95933 --- /dev/null +++ b/src/components/Users/index.js @@ -0,0 +1,429 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import _ from 'lodash' +import cn from 'classnames' +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 } from '../../config/constants' +import { checkAdmin } from '../../util/tc' +import { addUserToProject, removeUserFromProject } from '../../services/projects' +import { wait } from '../../util/helper' +import ConfirmationModal from '../Modal/ConfirmationModal' + +const theme = { + container: styles.modalContainer +} + +class Users extends Component { + constructor (props) { + super(props) + this.state = { + projectOption: null, + showAddUserModal: false, + userToAdd: null, + userPermissionToAdd: PROJECT_ROLES.READ, + showSelectUserError: false, + isAdding: false, + addUserError: false, + isRemoving: false, + removeError: null, + showRemoveConfirmationModal: false, + userToRemove: null + } + this.setProjectOption = this.setProjectOption.bind(this) + this.onAddUserClick = this.onAddUserClick.bind(this) + 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.onRemoveClick = this.onRemoveClick.bind(this) + this.resetRemoveUserState = this.resetRemoveUserState.bind(this) + this.onRemoveConfirmClick = this.onRemoveConfirmClick.bind(this) + } + + setProjectOption (projectOption) { + this.setState({ projectOption }) + const { loadProject } = this.props + loadProject(projectOption.value, false) + } + + updatePermission (newRole) { + this.setState({ + userPermissionToAdd: newRole + }) + } + + onAddUserClick () { + this.setState({ + showAddUserModal: true + }) + } + + resetAddUserState () { + this.setState({ + userToAdd: null, + showSelectUserError: false, + isAdding: false, + showAddUserModal: false, + userPermissionToAdd: PROJECT_ROLES.READ, + addUserError: null + }) + } + + onUpdateUserToAdd (option) { + let userToAdd = null + if (option && option.value) { + userToAdd = { + handle: option.label, + userId: parseInt(option.value, 10) + } + } + + this.setState({ + userToAdd, + showSelectUserError: !userToAdd + }) + } + + async onAddUserConfirmClick () { + console.log('in onAddUserConfirmClick') + console.log('in onAddUserConfirmClick this.state.userToAdd', this.state.userToAdd) + const { reloadProjectMembers } = this.props + if (this.state.isAdding) { return } + + this.setState({ + showSelectUserError: false, + addUserError: null + }) + + if (!this.state.userToAdd) { + console.log('in if') + this.setState({ + showSelectUserError: true + }) + return + } + + this.setState({ + isAdding: true + }) + + try { + await addUserToProject(this.state.projectOption.value, this.state.userToAdd.userId, this.state.userPermissionToAdd) + // wait for a second so that project's members are updated + await wait(1000) + if (this.state.projectOption.value) { reloadProjectMembers(this.state.projectOption.value) } + this.resetAddUserState() + } catch (e) { + const error = _.get( + e, + 'response.data.message', + `Unable to add user` + ) + this.setState({ isAdding: false, addUserError: error }) + } + } + + getHandle () { + return this.props.auth && this.props.auth.user + ? this.props.auth.user.handle + : null + } + + getMemberRole (members, handle) { + if (!handle) { return null } + + const found = _.find(members, (m) => { + return m.handle === handle + }) + + return _.get(found, 'role') + } + + onRemoveClick (user) { + if (this.state.isRemoving) { + return + } + + this.setState({ + showRemoveConfirmationModal: true, + userToRemove: user + }) + } + + resetRemoveUserState () { + this.setState({ + isRemoving: false, + showRemoveConfirmationModal: false, + userToRemove: null, + removeError: null + }) + } + + async onRemoveConfirmClick () { + if (this.state.isRemoving) { return } + + const { reloadProjectMembers } = this.props + const userToRemove = this.state.userToRemove + try { + this.setState({ isRemoving: true }) + await removeUserFromProject(userToRemove.projectId, userToRemove.id) + await wait(1000) + if (this.state.projectOption.value) { reloadProjectMembers(this.state.projectOption.value) } + + this.resetRemoveUserState() + } catch (e) { + const error = _.get( + e, + 'response.data.message', + `Unable to remove user` + ) + this.setState({ isRemoving: false, removeError: error }) + } + } + + checkIsCopilotOrManager (projectMembers, handle) { + if (projectMembers && projectMembers.length > 0) { + const role = this.getMemberRole(projectMembers, handle) + return role === PROJECT_ROLES.COPILOT || role === PROJECT_ROLES.MANAGER + } else { + return false + } + } + + render () { + const { projects, projectMembers, reloadProjectMembers, isEditable } = this.props + const projectOptions = projects.map(p => { + return { + label: p.name, + value: p.id + } + }) + const loggedInHandle = this.getHandle() + const membersExist = projectMembers && projectMembers.length > 0 + const isCopilotOrManager = this.checkIsCopilotOrManager(projectMembers, loggedInHandle) + const isAdmin = checkAdmin(this.props.auth.token) + const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin) + + return ( +
+
+
+
+ +
+
+ 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.showRemoveConfirmationModal && ( + + ) + } + { + membersExist && ( + <> +
+
+ User +
+
+ Read +
+
+ Write +
+
+ Full Access +
+
+ Copilot +
+
+
    + { + _.map(projectMembers, (member) => { + return ( +
  • + +
  • + ) + }) + } +
+ + ) + } + +
+ ) + } +} + +Users.propTypes = { + loadProject: PropTypes.func.isRequired, + reloadProjectMembers: PropTypes.func.isRequired, + auth: PropTypes.object, + isEditable: PropTypes.bool, + projects: PropTypes.arrayOf(PropTypes.object), + projectMembers: PropTypes.arrayOf(PropTypes.object) +} + +export default Users diff --git a/src/config/constants.js b/src/config/constants.js index a37f2768..e25915cd 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -20,7 +20,10 @@ export const { CHALLENGE_TYPE_ID, MARATHON_TYPE_ID, SEGMENT_API_KEY, - MULTI_ROUND_CHALLENGE_TEMPLATE_ID + MULTI_ROUND_CHALLENGE_TEMPLATE_ID, + UNIVERSAL_NAV_URL, + HEADER_AUTH_URLS_HREF, + HEADER_AUTH_URLS_LOCATION } = 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 @@ -94,11 +97,14 @@ export const LOAD_PROJECT_DETAILS_SUCCESS = 'LOAD_PROJECT_DETAILS_SUCCESS' export const LOAD_PROJECT_DETAILS_PENDING = 'LOAD_PROJECT_DETAILS_PENDING' export const LOAD_PROJECT_DETAILS_FAILURE = 'LOAD_PROJECT_DETAILS_FAILURE' +export const UPDATE_PROJECT_ROLE_FOR_MEMBER_SUCCESS = 'UPDATE_PROJECT_ROLE_FOR_MEMBER_SUCCESS' + export const LOAD_CHALLENGE_SUBMISSIONS = 'LOAD_CHALLENGE_SUBMISSIONS' export const LOAD_CHALLENGE_SUBMISSIONS_SUCCESS = 'LOAD_CHALLENGE_SUBMISSIONS_SUCCESS' export const LOAD_CHALLENGE_SUBMISSIONS_PENDING = 'LOAD_CHALLENGE_SUBMISSIONS_PENDING' export const LOAD_CHALLENGE_SUBMISSIONS_FAILURE = 'LOAD_CHALLENGE_SUBMISSIONS_FAILURE' +export const LOAD_CHALLENGE_MEMBERS = 'LOAD_CHALLENGE_MEMBERS' export const LOAD_CHALLENGE_MEMBERS_SUCCESS = 'LOAD_CHALLENGE_MEMBERS_SUCCESS' export const LOAD_CHALLENGE_METADATA_SUCCESS = 'LOAD_CHALLENGE_METADATA_SUCCESS' @@ -180,6 +186,13 @@ export const MARATHON_MATCH_SUBTRACKS = [ 'DEVELOP_MARATHON_MATCH' ] +export const PROJECT_ROLES = { + READ: 'observer', + WRITE: 'customer', + MANAGER: 'manager', + COPILOT: 'copilot' +} + export const CHALLENGE_STATUS = { ACTIVE: 'ACTIVE', NEW: 'NEW', @@ -211,7 +224,12 @@ export const ALLOWED_USER_ROLES = [ 'administrator', 'connect admin', 'connect manager', - 'connect copilot' + 'connect copilot', + 'topcoder user' +] + +export const READ_ONLY_ROLES = [ + 'topcoder user' ] export const ADMIN_ROLES = [ @@ -222,7 +240,7 @@ export const ADMIN_ROLES = [ export const downloadAttachmentURL = (challengeId, attachmentId, token) => `${CHALLENGE_API_URL}/${challengeId}/attachments/${attachmentId}/download?token=${token}` -export const PAGE_SIZE = 50 +export const PAGE_SIZE = 10 /** * The minimal number of characters to enter before starting showing autocomplete suggestions @@ -301,3 +319,10 @@ export const MULTI_ROUND_CHALLENGE_DESC_TEMPLATE = '\n\n### ROUND 1\n' + export const MAX_CHECKPOINT_PRIZE_COUNT = 8 export const DEFAULT_CHECKPOINT_PRIZE = 50 export const DEFAULT_CHECKPOINT_PRIZE_COUNT = 5 + +export const PAGINATION_PER_PAGE_OPTIONS = [ + { label: '5', value: '5' }, + { label: '10', value: '10' }, + { label: '25', value: '25' }, + { label: '50', value: '50' } +] diff --git a/src/containers/ChallengeEditor/index.js b/src/containers/ChallengeEditor/index.js index 66843d05..361dd337 100644 --- a/src/containers/ChallengeEditor/index.js +++ b/src/containers/ChallengeEditor/index.js @@ -36,7 +36,7 @@ import { loadSubmissions } from '../../actions/challengeSubmissions' import { loadProject } from '../../actions/projects' import { connect } from 'react-redux' -import { SUBMITTER_ROLE_UUID, MESSAGE } from '../../config/constants' +import { SUBMITTER_ROLE_UUID, MESSAGE, PROJECT_ROLES } from '../../config/constants' import { patchChallenge } from '../../services/challenges' import ConfirmationModal from '../../components/Modal/ConfirmationModal' import AlertModal from '../../components/Modal/AlertModal' @@ -59,7 +59,8 @@ class ChallengeEditor extends Component { showSuccessModal: false, showLaunchModal: false, showRejectModal: false, - cancelReason: null + cancelReason: null, + loginUserRoleInProject: '' } this.onLaunchChallenge = this.onLaunchChallenge.bind(this) @@ -125,7 +126,7 @@ class ChallengeEditor extends Component { componentWillReceiveProps (nextProps) { const { match } = this.props - const { match: newMatch, loadChallengeDetails, loadResources, loadSubmissions } = nextProps + const { match: newMatch, loadChallengeDetails, loadResources, loadSubmissions, projectDetail, loggedInUser } = nextProps const projectId = _.get(newMatch.params, 'projectId', null) const challengeId = _.get(newMatch.params, 'challengeId', null) if ( @@ -136,6 +137,15 @@ class ChallengeEditor extends Component { } else { this.setState({ challengeDetails: nextProps.challengeDetails }) } + if (projectDetail && loggedInUser) { + const projectMembers = projectDetail.members + const loginUserProjectInfo = _.find(projectMembers, { userId: loggedInUser.userId }) + if (loginUserProjectInfo && this.state.loginUserRoleInProject !== loginUserProjectInfo.role) { + this.setState({ + loginUserRoleInProject: loginUserProjectInfo.role + }) + } + } } async fetchProjectDetails (newMatch) { @@ -175,6 +185,10 @@ class ChallengeEditor extends Component { if (isAdmin) { return true } + const { loginUserRoleInProject } = this.state + if (loginUserRoleInProject === PROJECT_ROLES.READ) { + return false + } const userRoles = _.filter( challengeResources, cr => cr.memberId === `${loggedInUser.userId}` @@ -532,6 +546,7 @@ class ChallengeEditor extends Component { rejectChallenge={this.rejectChallenge} showRejectChallengeModal={showRejectChallengeModal} loggedInUser={loggedInUser} + enableEdit={enableEdit} /> )} /> @@ -571,6 +586,7 @@ class ChallengeEditor extends Component { loggedInUser={loggedInUser} projectPhases={projectPhases} assignYourselfCopilot={this.assignYourselfCopilot} + enableEdit={enableEdit} /> )} /> diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index 6a12f204..89dee8db 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -6,14 +6,21 @@ import React, { Component, Fragment } from 'react' // import { Redirect } from 'react-router-dom' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { DebounceInput } from 'react-debounce-input' import ChallengesComponent from '../../components/ChallengesComponent' import ProjectCard from '../../components/ProjectCard' -import Loader from '../../components/Loader' -import { loadChallengesByPage, partiallyUpdateChallengeDetails, deleteChallenge } from '../../actions/challenges' +// import Loader from '../../components/Loader' +import { + loadChallengesByPage, + partiallyUpdateChallengeDetails, + deleteChallenge, + loadChallengeTypes +} from '../../actions/challenges' import { loadProject } from '../../actions/projects' -import { loadProjects, setActiveProject, resetSidebarActiveParams } from '../../actions/sidebar' -import { CHALLENGE_STATUS } from '../../config/constants' +import { + loadProjects, + setActiveProject, + resetSidebarActiveParams +} from '../../actions/sidebar' import styles from './Challenges.module.scss' import { checkAdmin } from '../../util/tc' @@ -21,39 +28,70 @@ class Challenges extends Component { constructor (props) { super(props) this.state = { - searchProjectName: '', onlyMyProjects: true } - this.updateProjectName = this.updateProjectName.bind(this) - this.toggleMyProjects = this.toggleMyProjects.bind(this) } componentDidMount () { - const { activeProjectId, resetSidebarActiveParams, menu, projectId, selfService } = this.props + const { + dashboard, + activeProjectId, + resetSidebarActiveParams, + menu, + projectId, + selfService, + loadChallengeTypes + } = this.props + loadChallengeTypes() + if (dashboard) { + this.reloadChallenges(this.props, true) + } if (menu === 'NULL' && activeProjectId !== -1) { resetSidebarActiveParams() } else if (projectId || selfService) { - if (projectId) { + if (projectId && projectId !== -1) { window.localStorage.setItem('projectLoading', 'true') this.props.loadProject(projectId) } - this.reloadChallenges(this.props) + this.reloadChallenges(this.props, true) } } componentWillReceiveProps (nextProps) { - if (this.props.activeProjectId !== nextProps.activeProjectId) { + if ( + (nextProps.dashboard && this.props.dashboard !== nextProps.dashboard) || + this.props.activeProjectId !== nextProps.activeProjectId + ) { this.reloadChallenges(nextProps) } } - reloadChallenges (props) { - const { activeProjectId, projectDetail: reduxProjectInfo, projectId, challengeProjectId, loadProject, selfService } = props - if (activeProjectId !== challengeProjectId || selfService) { + reloadChallenges (props, forceLoad) { + const { + activeProjectId, + projectDetail: reduxProjectInfo, + projectId, + dashboard, + challengeProjectId, + loadProject, + selfService + } = props + if (activeProjectId !== challengeProjectId || selfService || forceLoad) { const isAdmin = checkAdmin(this.props.auth.token) - this.props.loadChallengesByPage(1, projectId ? parseInt(projectId) : -1, CHALLENGE_STATUS.ACTIVE, '', selfService, isAdmin ? null : this.props.auth.user.handle) - const projectLoading = window.localStorage.getItem('projectLoading') !== null - if (!selfService && (!reduxProjectInfo || `${reduxProjectInfo.id}` !== projectId) && !projectLoading + this.props.loadChallengesByPage( + 1, + projectId ? parseInt(projectId) : -1, + dashboard ? 'all' : '', + '', + selfService, + isAdmin ? null : this.props.auth.user.handle + ) + const projectLoading = + window.localStorage.getItem('projectLoading') !== null + if ( + !selfService && + (!reduxProjectInfo || `${reduxProjectInfo.id}` !== projectId) && + !projectLoading ) { loadProject(projectId) } else { @@ -62,23 +100,17 @@ class Challenges extends Component { } } - updateProjectName (val) { - this.setState({ searchProjectName: val }) - this.props.loadProjects(val, this.state.onlyMyProjects) - } - - toggleMyProjects (evt) { - this.setState({ onlyMyProjects: evt.target.checked }, () => { - this.props.loadProjects(this.state.searchProjectName, this.state.onlyMyProjects) - }) - } - render () { const { challenges, isLoading, warnMessage, filterChallengeName, + filterChallengeType, + filterDate, + filterSortBy, + filterSortOrder, + filterProjectOption, projects, activeProjectId, status, @@ -95,87 +127,84 @@ class Challenges extends Component { billingEndDate, isBillingAccountLoadingFailed, isBillingAccountLoading, + dashboard, selfService, - auth + auth, + metadata } = this.props - const { searchProjectName, onlyMyProjects } = this.state + const { challengeTypes = [] } = metadata const projectInfo = _.find(projects, { id: activeProjectId }) || {} - const projectComponents = projects.map(p => ( -
  • - -
  • - )) + const projectComponents = + !dashboard && + projects.map(p => ( +
  • + +
  • + )) return ( -
    - { - !selfService && ( -
    - - this.updateProjectName(e.target.value)} - value={searchProjectName} - /> - - -
    - ) - } - { - activeProjectId === -1 && !selfService &&
    No project selected. Select one below
    - } - { - isLoading ? : ( -
      - {projectComponents} -
    - ) - } -
    - {(activeProjectId !== -1 || selfService) && - } + {!dashboard && + (!!projectComponents.length || + (activeProjectId === -1 && !selfService)) ? ( +
    + {activeProjectId === -1 && !selfService && ( +
    No project selected. Select one below
    + )} +
      {projectComponents}
    +
    + ) : null} + {(dashboard || activeProjectId !== -1 || selfService) && ( + + )}
    ) } } +Challenges.defaultProps = { + isLoading: false +} + Challenges.propTypes = { projects: PropTypes.arrayOf(PropTypes.shape()), menu: PropTypes.string, @@ -187,13 +216,17 @@ Challenges.propTypes = { projectId: PropTypes.string, activeProjectId: PropTypes.number, warnMessage: PropTypes.string, + filterChallengeType: PropTypes.shape(), filterChallengeName: PropTypes.string, + filterProjectOption: PropTypes.shape(), + filterDate: PropTypes.shape(), + filterSortBy: PropTypes.string, + filterSortOrder: PropTypes.string, status: PropTypes.string, resetSidebarActiveParams: PropTypes.func, page: PropTypes.number.isRequired, perPage: PropTypes.number.isRequired, totalChallenges: PropTypes.number.isRequired, - loadProjects: PropTypes.func.isRequired, setActiveProject: PropTypes.func.isRequired, partiallyUpdateChallengeDetails: PropTypes.func.isRequired, deleteChallenge: PropTypes.func.isRequired, @@ -203,7 +236,12 @@ Challenges.propTypes = { isBillingAccountLoadingFailed: PropTypes.bool, isBillingAccountLoading: PropTypes.bool, selfService: PropTypes.bool, - auth: PropTypes.object.isRequired + dashboard: PropTypes.bool, + auth: PropTypes.object.isRequired, + loadChallengeTypes: PropTypes.func, + metadata: PropTypes.shape({ + challengeTypes: PropTypes.array + }) } const mapStateToProps = ({ challenges, sidebar, projects, auth }) => ({ @@ -217,7 +255,8 @@ const mapStateToProps = ({ challenges, sidebar, projects, auth }) => ({ billingEndDate: projects.billingEndDate, isBillingAccountLoadingFailed: projects.isBillingAccountLoadingFailed, isBillingAccountLoading: projects.isBillingAccountLoading, - auth: auth + auth: auth, + metadata: challenges.metadata }) const mapDispatchToProps = { @@ -225,6 +264,7 @@ const mapDispatchToProps = { resetSidebarActiveParams, loadProject, loadProjects, + loadChallengeTypes, setActiveProject, partiallyUpdateChallengeDetails, deleteChallenge diff --git a/src/containers/FooterContainer/index.js b/src/containers/FooterContainer/index.js new file mode 100644 index 00000000..190061b6 --- /dev/null +++ b/src/containers/FooterContainer/index.js @@ -0,0 +1,16 @@ +/** + * Container to provide user info to TopBar component + */ +import React, { Component } from 'react' +import TopcoderFooter from '../../components/TopcoderFooter' + +class FooterContainer extends Component { + render () { + return + } +} + +FooterContainer.propTypes = { +} + +export default FooterContainer diff --git a/src/containers/Tab/index.js b/src/containers/Tab/index.js new file mode 100644 index 00000000..553de850 --- /dev/null +++ b/src/containers/Tab/index.js @@ -0,0 +1,127 @@ +import React, { Component } from 'react' +import { withRouter } from 'react-router-dom' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import Tab from '../../components/Tab' +import { + loadProjects, + setActiveProject, + resetSidebarActiveParams, + unloadProjects +} from '../../actions/sidebar' + +class TabContainer extends Component { + constructor (props) { + super(props) + this.state = { + searchProjectName: '', + currentTab: 1 + } + this.updateProjectName = this.updateProjectName.bind(this) + this.onTabChange = this.onTabChange.bind(this) + } + + componentDidMount () { + const { projectId, activeProjectId, isLoading, selfService } = this.props + if (!projectId && activeProjectId === -1 && !isLoading && !selfService) { + this.props.loadProjects() + } + + if (projectId && activeProjectId < 0) { + this.props.setActiveProject(parseInt(projectId)) + } + } + + componentWillReceiveProps (nextProps) { + const { projectId, isLoading, selfService, projects, isLoadProjectsSuccess } = nextProps + + if (nextProps.history.location.pathname === '/') { + this.setState({ currentTab: 1 }) + } else if (nextProps.history.location.pathname === '/projects') { + this.setState({ currentTab: 2 }) + } else if (nextProps.history.location.pathname === '/users') { + this.setState({ currentTab: 3 }) + } else { + this.setState({ currentTab: 0 }) + } + // if we're viewing a specific project, + // or we're viewing the self serve page, + // or if the project is already loading, + // don't load the projects + if (!!projectId || selfService || isLoading) { + // if we're not in the middle of loading, + // and we have projects to unload, + // unload them + if (!isLoading && !!projects && !!projects.length) { + this.props.unloadProjects() + } + + return + } + + // if we already have projects in the list, + // don't load the projects again + if ((!!projects && !!projects.length) || isLoadProjectsSuccess) { + return + } + + // now it's okay to load the projects + this.props.loadProjects() + } + + updateProjectName (val) { + this.setState({ searchProjectName: val }) + this.props.loadProjects(val) + } + + onTabChange (tab) { + const { history, resetSidebarActiveParams } = this.props + if (tab === 1) { + history.push('/') + this.setState({ currentTab: 1 }) + } else if (tab === 2) { + history.push('/projects') + this.setState({ currentTab: 2 }) + } else if (tab === 3) { + history.push('/users') + this.setState({ currentTab: 3 }) + } + + resetSidebarActiveParams() + } + + render () { + const { currentTab } = this.state + + return + } +} + +TabContainer.propTypes = { + projects: PropTypes.arrayOf(PropTypes.shape()), + isLoading: PropTypes.bool, + isLoadProjectsSuccess: PropTypes.bool, + loadProjects: PropTypes.func, + unloadProjects: PropTypes.func, + activeProjectId: PropTypes.number, + history: PropTypes.any.isRequired, + setActiveProject: PropTypes.func, + projectId: PropTypes.string, + resetSidebarActiveParams: PropTypes.func, + selfService: PropTypes.bool +} + +const mapStateToProps = ({ sidebar }) => ({ + ...sidebar +}) + +const mapDispatchToProps = { + loadProjects, + unloadProjects, + setActiveProject, + resetSidebarActiveParams +} + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(TabContainer) +) diff --git a/src/containers/TopbarContainer/index.js b/src/containers/TopbarContainer/index.js index 90a4d646..20df77c0 100644 --- a/src/containers/TopbarContainer/index.js +++ b/src/containers/TopbarContainer/index.js @@ -21,17 +21,13 @@ class TopbarContainer extends Component { } render () { - const { match } = this.props - const isChalengeViewPage = match.path === '/projects/:projectId/challenges/:challengeId' - const { user } = this.props.auth - return + return } } TopbarContainer.propTypes = { loadUser: PropTypes.func.isRequired, setActiveProject: PropTypes.func.isRequired, - match: PropTypes.any.isRequired, auth: PropTypes.object.isRequired, activeProjectId: PropTypes.number, projectId: PropTypes.string diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js new file mode 100644 index 00000000..78f2c141 --- /dev/null +++ b/src/containers/Users/index.js @@ -0,0 +1,94 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' +import _ from 'lodash' +import PT from 'prop-types' +import UsersComponent from '../../components/Users' +import { loadProjects } from '../../actions/sidebar' +import { loadProject, reloadProjectMembers } from '../../actions/projects' +import { PROJECT_ROLES } from '../../config/constants' + +class Users extends Component { + constructor (props) { + super(props) + + this.state = { + loginUserRoleInProject: '' + } + } + + componentDidMount () { + this.props.loadProjects() + } + + isEditable () { + const { loginUserRoleInProject } = this.state + if (loginUserRoleInProject === PROJECT_ROLES.READ) { + return false + } + return true + } + + componentWillReceiveProps (nextProps) { + const { projectDetail, loggedInUser } = nextProps + if (projectDetail && loggedInUser) { + const projectMembers = projectDetail.members + const loginUserProjectInfo = _.find(projectMembers, { userId: loggedInUser.userId }) + if (loginUserProjectInfo && this.state.loginUserRoleInProject !== loginUserProjectInfo.role) { + this.setState({ + loginUserRoleInProject: loginUserProjectInfo.role + }) + } + } + } + + render () { + const { + projects, + loadProject, + projectMembers, + auth, + reloadProjectMembers, + projectDetail + } = this.props + return ( + + ) + } +} + +const mapStateToProps = ({ sidebar, challenges, auth, projects }) => { + return { + projects: sidebar.projects, + projectMembers: _.get(challenges, 'metadata.members'), + projectDetail: projects.projectDetail, + auth, + loggedInUser: auth.user + } +} + +const mapDispatchToProps = { + loadProject, + loadProjects, + reloadProjectMembers +} + +Users.propTypes = { + loadProject: PT.func.isRequired, + loadProjects: PT.func.isRequired, + reloadProjectMembers: PT.func.isRequired, + projects: PT.arrayOf(PT.object), + projectMembers: PT.arrayOf(PT.object), + auth: PT.object, + projectDetail: PT.object, + loggedInUser: PT.object +} + +export default connect(mapStateToProps, mapDispatchToProps)(Users) diff --git a/src/index.js b/src/index.js index def73426..82fb6376 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import ReactDOM from 'react-dom' import './styles/main.scss' import 'react-redux-toastr/lib/css/react-redux-toastr.min.css' import App from './App' -import { SEGMENT_API_KEY } from './config/constants' +import { SEGMENT_API_KEY, UNIVERSAL_NAV_URL } from './config/constants' ReactDOM.render(, document.getElementById('root')) @@ -18,3 +18,29 @@ if (!_.isEmpty(SEGMENT_API_KEY)) { }}(); } /* eslint-enable */ + +// +// eslint-disable-next-line no-unused-expressions +!(function (n, t, e, a, c, i, o) { +// eslint-disable-next-line no-unused-expressions, no-sequences + ;(n['TcUnivNavConfig'] = c), + (n[c] = + n[c] || + function () { + ;(n[c].q = n[c].q || []).push(arguments) + }), + (n[c].l = 1 * new Date()) + // eslint-disable-next-line no-unused-expressions, no-sequences + ;(i = t.createElement(e)), (o = t.getElementsByTagName(e)[0]) + i.async = 1 + i.type = 'module' + i.src = a + o.parentNode.insertBefore(i, o) +})( + window, + document, + 'script', + UNIVERSAL_NAV_URL, + 'tcUniNav' +) +// diff --git a/src/reducers/challenges.js b/src/reducers/challenges.js index 71f85fda..56b816dd 100644 --- a/src/reducers/challenges.js +++ b/src/reducers/challenges.js @@ -32,7 +32,8 @@ import { CREATE_CHALLENGE_RESOURCE_FAILURE, DELETE_CHALLENGE_SUCCESS, DELETE_CHALLENGE_FAILURE, - DELETE_CHALLENGE_PENDING + DELETE_CHALLENGE_PENDING, + MULTI_ROUND_CHALLENGE_TEMPLATE_ID } from '../config/constants' const initialState = { @@ -47,6 +48,10 @@ const initialState = { attachments: [], challenge: null, filterChallengeName: '', + filterChallengeType: {}, + filterDate: {}, + filterSortBy: 'startDate', + filterSortOrder: 'desc', failedToDelete: false, status: '', perPage: 0, @@ -84,6 +89,11 @@ export default function (state = initialState, action) { projectId: action.projectId, status: action.status, filterChallengeName: action.filterChallengeName, + filterProjectOption: action.filterProjectOption, + filterChallengeType: action.filterChallengeType, + filterDate: action.filterDate, + filterSortBy: action.filterSortBy, + filterSortOrder: action.filterSortOrder, perPage: action.perPage, page: action.page } @@ -97,7 +107,18 @@ export default function (state = initialState, action) { case LOAD_CHALLENGE_DETAILS_SUCCESS: { return { ...state, - challengeDetails: action.payload, + challengeDetails: { ...action.payload, + // change the phase order for the design challenge with multiple phases + phases: (action.payload.timelineTemplateId === MULTI_ROUND_CHALLENGE_TEMPLATE_ID && action.payload.phases.length === 8) ? [ + action.payload.phases.find(x => x.name === 'Registration'), + action.payload.phases.find(x => x.name === 'Checkpoint Submission'), + action.payload.phases.find(x => x.name === 'Checkpoint Screening'), + action.payload.phases.find(x => x.name === 'Checkpoint Review'), + action.payload.phases.find(x => x.name === 'Submission'), + action.payload.phases.find(x => x.name === 'Screening'), + action.payload.phases.find(x => x.name === 'Review'), + action.payload.phases.find(x => x.name === 'Approval')] : action.payload.phases + }, isLoading: false, attachments: _.has(action.payload, 'attachments') ? action.payload.attachments : [], failedToLoad: false @@ -141,7 +162,18 @@ export default function (state = initialState, action) { return { ...state, challenges: updatedChallenges, - challengeDetails: action.challengeDetails, + challengeDetails: { ...action.challengeDetails, + // change the phase order for the design challenge with multiple phases + phases: (action.challengeDetails.timelineTemplateId === MULTI_ROUND_CHALLENGE_TEMPLATE_ID && action.challengeDetails.phases.length === 8) ? [ + action.challengeDetails.phases.find(x => x.name === 'Registration'), + action.challengeDetails.phases.find(x => x.name === 'Checkpoint Submission'), + action.challengeDetails.phases.find(x => x.name === 'Checkpoint Screening'), + action.challengeDetails.phases.find(x => x.name === 'Checkpoint Review'), + action.challengeDetails.phases.find(x => x.name === 'Submission'), + action.challengeDetails.phases.find(x => x.name === 'Screening'), + action.challengeDetails.phases.find(x => x.name === 'Review'), + action.challengeDetails.phases.find(x => x.name === 'Approval')] : action.challengeDetails.phases + }, isLoading: false, attachments: _.has(action.challengeDetails, 'attachments') ? action.challengeDetails.attachments : [], failedToLoad: false @@ -242,7 +274,7 @@ export default function (state = initialState, action) { } } case LOAD_CHALLENGE_MEMBERS_SUCCESS: { - return { ...state, metadata: { ...state.metadata, members: action.members } } + return { ...state, metadata: { ...state.metadata, members: action.payload } } } case CREATE_ATTACHMENT_PENDING: { const attachments = [ diff --git a/src/reducers/sidebar.js b/src/reducers/sidebar.js index 54e2cf45..6bf04611 100644 --- a/src/reducers/sidebar.js +++ b/src/reducers/sidebar.js @@ -12,7 +12,8 @@ import { const initialState = { activeProjectId: -1, isLoading: false, - projects: [] + projects: [], + isLoadProjectsSuccess: false } export default function (state = initialState, action) { @@ -20,7 +21,7 @@ export default function (state = initialState, action) { case SET_ACTIVE_PROJECT: return { ...state, activeProjectId: action.projectId, projects: [], isLoading: false } case LOAD_PROJECTS_SUCCESS: - return { ...state, projects: action.projects, isLoading: false, isLoggedIn: true } + return { ...state, projects: action.projects, isLoading: false, isLoggedIn: true, isLoadProjectsSuccess: true } case LOAD_PROJECTS_PENDING: return { ...state, isLoading: true } case LOAD_PROJECTS_FAILURE: diff --git a/src/routes.js b/src/routes.js index fbe0be96..df3d102c 100644 --- a/src/routes.js +++ b/src/routes.js @@ -8,18 +8,20 @@ import _ from 'lodash' import { BETA_MODE_COOKIE_TAG } from './config/constants' import renderApp from './components/App' import TopBarContainer from './containers/TopbarContainer' -import Sidebar from './containers/Sidebar' +import FooterContainer from './containers/FooterContainer' +import Tab from './containers/Tab' import Challenges from './containers/Challenges' import ChallengeEditor from './containers/ChallengeEditor' import { getFreshToken, decodeToken } from 'tc-auth-lib' import { saveToken } from './actions/auth' import { loadChallengeDetails } from './actions/challenges' import { connect } from 'react-redux' -import { checkAllowedRoles } from './util/tc' +import { checkAllowedRoles, checkReadOnlyRoles } from './util/tc' import { setCookie, removeCookie, isBetaMode } from './util/cookie' import IdleTimer from 'react-idle-timer' import modalStyles from './styles/modal.module.scss' import ConfirmationModal from './components/Modal/ConfirmationModal' +import Users from './containers/Users' const { ACCOUNTS_APP_LOGIN_URL, IDLE_TIMEOUT_MINUTES, IDLE_TIMEOUT_GRACE_MINUTES, COMMUNITY_APP_URL } = process.env @@ -35,9 +37,11 @@ class RedirectToChallenge extends React.Component { } componentWillReceiveProps (nextProps) { + const { token } = nextProps + const isReadOnly = checkReadOnlyRoles(token) const projectId = _.get(nextProps.challengeDetails, 'projectId') const challengeId = _.get(nextProps.challengeDetails, 'id') - if (projectId && challengeId) { + if (projectId && challengeId && isReadOnly) { console.log('Redircting to full URL') this.props.history.replace(`/projects/${projectId}/challenges/${challengeId}/view`) } @@ -60,7 +64,8 @@ RedirectToChallenge.propTypes = { loadChallengeDetails: PropTypes.func, challengeDetails: PropTypes.object, match: PropTypes.object, - history: PropTypes.object + history: PropTypes.object, + token: PropTypes.string } const ConnectRedirectToChallenge = connect(mapStateToProps, mapDispatchToProps)(RedirectToChallenge) @@ -127,6 +132,7 @@ class Routes extends React.Component { } const isAllowed = checkAllowedRoles(_.get(decodeToken(this.props.token), 'roles')) + const isReadOnly = checkReadOnlyRoles(this.props.token) const modal = ( renderApp( , , - + , + )()} /> @@ -163,37 +170,66 @@ class Routes extends React.Component { {isAllowed && renderApp( - , + , + , + , + + )()} + /> + renderApp( + , , - + , + )()} /> + { + !isReadOnly && ( + renderApp( + , + , + , + + )()} + /> + ) + } renderApp( , , - + , + )()} /> - renderApp( - , - , - - )()} /> + { + !isReadOnly && ( + renderApp( + , + , + , + + )()} /> + ) + } renderApp( , , - + , + )()} /> renderApp( - , + , , - + , + )()} /> {/* If path is not defined redirect to landing page */} diff --git a/src/services/projects.js b/src/services/projects.js index 0279498e..50368519 100644 --- a/src/services/projects.js +++ b/src/services/projects.js @@ -57,6 +57,46 @@ export async function fetchProjectPhases (id) { return _.get(response, 'data') } +/** + * updates the role for the member for the given project id + * @param projectId project id + * @param memberRecordId the id for the member record to update + * @param newRole the new role + * @returns {Promise<*>} + */ +export async function updateProjectMemberRole (projectId, memberRecordId, newRole) { + const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`, { + role: newRole + }) + 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 addUserToProject (projectId, userId, role) { + const response = await axiosInstance.post(`${PROJECT_API_URL}/${projectId}/members`, { + userId, + role + }) + return _.get(response, 'data') +} + +/** + * removes the given member record from the project + * @param projectId project id + * @param memberRecordId member record id + * @returns {Promise<*>} + */ +export async function removeUserFromProject (projectId, memberRecordId) { + const response = await axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`) + return response +} + /** * Save challengeId as Phase product detail * @param projectId Project id diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss index 30b36b34..b640d949 100644 --- a/src/styles/_colors.scss +++ b/src/styles/_colors.scss @@ -44,6 +44,8 @@ $tc-green-30: #60C602; $tc-green-40: #35AC35; $tc-green-50: #127D60; +$tc-turquose-30: #0ab88a; + $tc-red: #BE405E; $tc-gray-00: #FFFFFF; @@ -64,3 +66,9 @@ $tc-handle-gray: #9D9FA0; $tc-prize-bg: #EBEBEB; $tc-multi-select-icon-bg-color: #c6def1; + +$track-code-purplish: linear-gradient(83.58deg, #7b21a7 2.28%, #1974ad 97.67%); +$track-code-red: linear-gradient(84.92deg, #880152 2.08%, #be4a1d 97.43%); +$track-code-yellow: inear-gradient(90deg, #9f9900 0%, #3b890b 100%); +$track-code-purple: linear-gradient(90deg, #652385 0%, #8c384c 100%); +$track-code-grey: linear-gradient(265.38deg, #323232 1.99%, #8c8c8c 98.19%); \ No newline at end of file diff --git a/src/util/date.js b/src/util/date.js index 32e2733c..915c3a98 100644 --- a/src/util/date.js +++ b/src/util/date.js @@ -116,7 +116,8 @@ export const updateChallengePhaseBeforeSendRequest = (challengeDetail) => { // challengeDetailTmp.submissionEndDate = moment(challengeDetail.phases[1].scheduledEndDate) challengeDetailTmp.phases = challengeDetailTmp.phases.map((p) => ({ duration: p.duration * hourToSecond, - phaseId: p.phaseId + phaseId: p.phaseId, + scheduledStartDate: p.scheduledStartDate })) return challengeDetailTmp } diff --git a/src/util/helper.js b/src/util/helper.js new file mode 100644 index 00000000..1e3e7d16 --- /dev/null +++ b/src/util/helper.js @@ -0,0 +1,10 @@ +/** + * Returns a promise that resolves after given milliseconds + * @param ms milli seconds + * @return {Promise} + */ +export const wait = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/util/tc.js b/src/util/tc.js index 9760a5de..c011f3af 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -6,7 +6,8 @@ import { CHALLENGE_TRACKS, ALLOWED_USER_ROLES, ADMIN_ROLES, - SUBMITTER_ROLE_UUID + SUBMITTER_ROLE_UUID, + READ_ONLY_ROLES } from '../config/constants' import _ from 'lodash' import { decodeToken } from 'tc-auth-lib' @@ -149,6 +150,18 @@ export const getDomainTypes = (trackId) => { export const checkAllowedRoles = roles => roles.some(val => ALLOWED_USER_ROLES.indexOf(val.toLowerCase()) > -1) +/** + * Checks if read only role is present in allowed roles + * @param token + */ +export const checkReadOnlyRoles = token => { + const roles = _.get(decodeToken(token), 'roles') + if (checkAllowedRoles(roles)) { + return false + } + return roles.some(val => READ_ONLY_ROLES.indexOf(val.toLowerCase()) > -1) +} + /** * Checks if token has any of the admin roles * @param token @@ -244,3 +257,15 @@ export function getFinalScore (submission) { } return finalScore } + +/** + * Get challenge type abbreviation + * @param {Object} challenge challenge info + */ +export function getChallengeTypeAbbr (track, challengeTypes) { + const type = _.find(challengeTypes, { name: track }) + if (type) { + return type.abbreviation + } + return null +} diff --git a/src/util/url.js b/src/util/url.js new file mode 100644 index 00000000..c8f14a9e --- /dev/null +++ b/src/util/url.js @@ -0,0 +1,9 @@ +/** + * Get initials from user profile + * @param {String} firstName first name + * @param {String} lastName last name + * @returns {String} + */ +export function getInitials (firstName = '', lastName = '') { + return `${firstName.slice(0, 1)}${lastName.slice(0, 1)}` +}