diff --git a/.circleci/config.yml b/.circleci/config.yml index a456a88e..d348291b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -160,7 +160,7 @@ workflows: context: org-global filters: &filters-dev branches: - only: ["develop", "pm-2917", "points", "pm-3270", "engagements"] + only: ["develop", "pm-2917", "points", "pm-3270", "projects-api-v6"] # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/constants/development.js b/config/constants/development.js index af3244d6..67b69f47 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -30,7 +30,7 @@ module.exports = { CHALLENGE_PHASES_URL: `${DEV_API_HOSTNAME}/v6/challenge-phases`, CHALLENGE_TIMELINES_URL: `${DEV_API_HOSTNAME}/v6/challenge-timelines`, COPILOTS_URL: 'https://copilots.topcoder-dev.com', - PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, + PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, GROUPS_API_URL: `${DEV_API_HOSTNAME}/v6/groups`, TERMS_API_URL: `${DEV_API_HOSTNAME}/v5/terms`, RESOURCES_API_URL: `${DEV_API_HOSTNAME}/v6/resources`, diff --git a/config/constants/local.js b/config/constants/local.js index ad2ae1da..c551b163 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -12,6 +12,7 @@ const LOCAL_MEMBER_API = 'http://localhost:3003/v6' const LOCAL_RESOURCE_API = 'http://localhost:3004/v6' const LOCAL_REVIEW_API = 'http://localhost:3005/v6' const LOCAL_SKILLS_API_V5 = 'http://localhost:3006/v5/standardized-skills' +const LOCAL_PROJECTS_API = 'http://localhost:3008/v6/projects' // Lookups API available on 3007 if needed in future // const LOCAL_LOOKUPS_API = 'http://localhost:3007/v6' @@ -46,8 +47,8 @@ module.exports = { // Copilots and other apps remain on dev COPILOTS_URL: 'https://copilots.topcoder-dev.com', - // Projects API: keep dev unless you run projects locally - PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, + // Projects API v6: keep dev default (switch to LOCAL_PROJECTS_API when needed) + PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, // Local groups/resources/review services GROUPS_API_URL: `${LOCAL_GROUPS_API}/groups`, diff --git a/config/constants/production.js b/config/constants/production.js index c4f69daf..94c3e95e 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -29,7 +29,7 @@ module.exports = { CHALLENGE_PHASES_URL: `${PROD_API_HOSTNAME}/v6/challenge-phases`, CHALLENGE_TIMELINES_URL: `${PROD_API_HOSTNAME}/v6/challenge-timelines`, COPILOTS_URL: `https://copilots.${DOMAIN}`, - PROJECT_API_URL: `${PROD_API_HOSTNAME}/v5/projects`, + PROJECT_API_URL: `${PROD_API_HOSTNAME}/v6/projects`, GROUPS_API_URL: `${PROD_API_HOSTNAME}/v6/groups`, TERMS_API_URL: `${PROD_API_HOSTNAME}/v5/terms`, MEMBERS_API_URL: `${PROD_API_HOSTNAME}/v5/members`, diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index 0de3fa0b..d22fd537 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -61,6 +61,45 @@ const normalizeTrackForScorecards = (challenge, metadata) => { return null } +const normalizePhaseToken = (value) => (value || '') + .toString() + .toLowerCase() + .trim() + .replace(/\bphase\b$/, '') + .replace(/[-_\s]/g, '') + +const normalizeIdValue = (value) => ( + value === undefined || value === null + ? '' + : value.toString() +) + +const getScorecardsForPhase = (scorecards = [], phases = [], phaseId) => { + const normalizedPhaseId = normalizeIdValue(phaseId) + if (!normalizedPhaseId) { + return [] + } + + const selectedPhase = phases.find(phase => ( + normalizeIdValue(phase.phaseId) === normalizedPhaseId || + normalizeIdValue(phase.id) === normalizedPhaseId + )) + + if (!selectedPhase || !selectedPhase.name) { + return [] + } + + const normalizedPhaseName = normalizePhaseToken(selectedPhase.name) + if (!normalizedPhaseName) { + return [] + } + + return scorecards.filter(scorecard => ( + scorecard && + normalizePhaseToken(scorecard.type) === normalizedPhaseName + )) +} + class ChallengeReviewerField extends Component { constructor (props) { super(props) @@ -602,6 +641,31 @@ class ChallengeReviewerField extends Component { baseCoefficient: defaultReviewer.baseCoefficient, incrementalCoefficient: defaultReviewer.incrementalCoefficient }) + + if (updatedReviewers[index] && (updatedReviewers[index].isMemberReview !== false)) { + const { metadata = {} } = this.props + const scorecardsForPhase = getScorecardsForPhase( + metadata.scorecards || [], + challenge.phases || [], + value + ) + const currentScorecardId = normalizeIdValue(updatedReviewers[index].scorecardId) + const hasCurrentScorecard = scorecardsForPhase.some(scorecard => ( + normalizeIdValue(scorecard.id) === currentScorecardId + )) + + if (!hasCurrentScorecard) { + const defaultScorecardId = normalizeIdValue(defaultReviewer && defaultReviewer.scorecardId) + const hasDefaultScorecard = defaultScorecardId && scorecardsForPhase.some(scorecard => ( + normalizeIdValue(scorecard.id) === defaultScorecardId + )) + const fallbackScorecardId = hasDefaultScorecard + ? defaultScorecardId + : normalizeIdValue(scorecardsForPhase[0] && scorecardsForPhase[0].id) + + fieldUpdate.scorecardId = fallbackScorecardId || '' + } + } } if (field === 'memberReviewerCount') { @@ -661,29 +725,12 @@ class ChallengeReviewerField extends Component { const { challenge, metadata = {}, readOnly = false } = this.props const { scorecards = [], workflows = [] } = metadata const validationErrors = challenge.submitTriggered ? this.validateReviewer(reviewer) : {} - const selectedPhase = challenge.phases.find(p => p.phaseId === reviewer.phaseId) + const filteredScorecards = getScorecardsForPhase( + scorecards, + challenge.phases || [], + reviewer.phaseId + ) const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID - const normalize = (value) => (value || '') - .toString() - .toLowerCase() - .trim() - .replace(/\bphase\b$/, '') - .replace(/[-_\s]/g, '') - - const filteredScorecards = scorecards.filter(item => { - if (!selectedPhase || !selectedPhase.name || !item || !item.type) { - return false - } - - const normalizedType = normalize(item.type) - const normalizedPhaseName = normalize(selectedPhase.name) - - if (!normalizedType || !normalizedPhaseName) { - return false - } - - return normalizedType === normalizedPhaseName - }) return (
diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index ee500aaa..8637bd6a 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -31,6 +31,7 @@ import { import PhaseInput from '../../PhaseInput' import CheckpointPrizesField from '../CheckpointPrizes-Field' import { isBetaMode } from '../../../util/localstorage' +import WiproAllowedField from '../WiproAllowedField' const ChallengeView = ({ projectDetail, @@ -95,6 +96,7 @@ const ChallengeView = ({ if (isLoading || _.isEmpty(metadata.challengePhases) || challenge.id !== challengeId) return const showTimeline = false // disables the timeline for time being https://github.com/topcoder-platform/challenge-engine-ui/issues/706 const isTask = _.get(challenge, 'task.isTask', false) + const isFunChallenge = challenge.funChallenge === true const phases = _.get(challenge, 'phases', []) const showCheckpointPrizes = _.get(challenge, 'timelineTemplateId') === MULTI_ROUND_CHALLENGE_TEMPLATE_ID const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' }) @@ -195,6 +197,7 @@ const ChallengeView = ({ <> {dashboardToggle} + {}} readOnly />
Groups: {groups} @@ -262,14 +265,24 @@ const ChallengeView = ({ token={token} readOnly />} - - { - showCheckpointPrizes && ( - - ) - } - - + {isFunChallenge ? ( +
+
+ Fun Challenge: True +
+
+ ) : ( + <> + + { + showCheckpointPrizes && ( + + ) + } + + + + )}
diff --git a/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss b/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss new file mode 100644 index 00000000..422b7433 --- /dev/null +++ b/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss @@ -0,0 +1,76 @@ +@use '../../../styles/includes' as *; + +.row { + box-sizing: border-box; + display: flex; + flex-direction: row; + margin: 30px 30px 0 30px; + align-content: space-between; + justify-content: flex-start; + + .tcCheckbox { + @include tc-checkbox; + + height: 18px; + width: 210px; + margin: 0; + padding: 0; + vertical-align: bottom; + position: relative; + display: inline-block; + + input[type='checkbox'] { + display: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 14px; + height: 14px; + top: 0; + left: 0; + border: none; + box-shadow: none; + background: $tc-gray-30; + transition: all 0.15s ease-in-out; + + &::after { + opacity: 0; + content: ''; + position: absolute; + width: 9px; + height: 5px; + background: transparent; + top: 2px; + left: 2px; + border-top: none; + border-right: none; + transform: rotate(-45deg); + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 300px; + } + } + + input[type='checkbox']:checked ~ label { + background: $tc-blue-20; + } + + input[type='checkbox']:checked + label::after { + border-color: $white; + } + } +} diff --git a/src/components/ChallengeEditor/FunChallengeField/index.js b/src/components/ChallengeEditor/FunChallengeField/index.js new file mode 100644 index 00000000..d826b916 --- /dev/null +++ b/src/components/ChallengeEditor/FunChallengeField/index.js @@ -0,0 +1,47 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './FunChallengeField.module.scss' + +/** + * Renders a checkbox to toggle the `funChallenge` flag for Marathon Match challenges. + * + * @param {Object} props component props + * @param {Object} props.challenge challenge data object that may include `funChallenge` + * @param {Function} props.onUpdateOthers callback used to update top-level challenge fields + * @param {boolean} props.readOnly when true, renders the control as read-only + * @returns {import('react').ReactNode} rendered fun challenge checkbox field + */ +const FunChallengeField = ({ challenge, onUpdateOthers, readOnly }) => { + const isFunChallenge = challenge.funChallenge === true + + return ( +
+
+ onUpdateOthers({ field: 'funChallenge', value: !isFunChallenge })} + /> + +
+
+ ) +} + +FunChallengeField.defaultProps = { + readOnly: false +} + +FunChallengeField.propTypes = { + challenge: PropTypes.shape().isRequired, + onUpdateOthers: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +export default FunChallengeField diff --git a/src/components/ChallengeEditor/WiproAllowedField/WiproAllowedField.module.scss b/src/components/ChallengeEditor/WiproAllowedField/WiproAllowedField.module.scss new file mode 100644 index 00000000..422b7433 --- /dev/null +++ b/src/components/ChallengeEditor/WiproAllowedField/WiproAllowedField.module.scss @@ -0,0 +1,76 @@ +@use '../../../styles/includes' as *; + +.row { + box-sizing: border-box; + display: flex; + flex-direction: row; + margin: 30px 30px 0 30px; + align-content: space-between; + justify-content: flex-start; + + .tcCheckbox { + @include tc-checkbox; + + height: 18px; + width: 210px; + margin: 0; + padding: 0; + vertical-align: bottom; + position: relative; + display: inline-block; + + input[type='checkbox'] { + display: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 14px; + height: 14px; + top: 0; + left: 0; + border: none; + box-shadow: none; + background: $tc-gray-30; + transition: all 0.15s ease-in-out; + + &::after { + opacity: 0; + content: ''; + position: absolute; + width: 9px; + height: 5px; + background: transparent; + top: 2px; + left: 2px; + border-top: none; + border-right: none; + transform: rotate(-45deg); + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 300px; + } + } + + input[type='checkbox']:checked ~ label { + background: $tc-blue-20; + } + + input[type='checkbox']:checked + label::after { + border-color: $white; + } + } +} diff --git a/src/components/ChallengeEditor/WiproAllowedField/index.js b/src/components/ChallengeEditor/WiproAllowedField/index.js new file mode 100644 index 00000000..303dab55 --- /dev/null +++ b/src/components/ChallengeEditor/WiproAllowedField/index.js @@ -0,0 +1,47 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './WiproAllowedField.module.scss' + +/** + * Renders a checkbox to toggle the `wiproAllowed` flag for challenges. + * + * @param {Object} props component props + * @param {Object} props.challenge challenge data object that may include `wiproAllowed` + * @param {Function} props.onUpdateOthers callback used to update top-level challenge fields + * @param {boolean} props.readOnly when true, renders the control as read-only + * @returns {import('react').ReactNode} rendered wipro allowed checkbox field + */ +const WiproAllowedField = ({ challenge, onUpdateOthers, readOnly }) => { + const isWiproAllowed = challenge.wiproAllowed === true + + return ( +
+
+ onUpdateOthers({ field: 'wiproAllowed', value: !isWiproAllowed })} + /> + +
+
+ ) +} + +WiproAllowedField.defaultProps = { + readOnly: false +} + +WiproAllowedField.propTypes = { + challenge: PropTypes.shape().isRequired, + onUpdateOthers: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +export default WiproAllowedField diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js index 88297520..de2c2cce 100644 --- a/src/components/ChallengeEditor/index.js +++ b/src/components/ChallengeEditor/index.js @@ -67,6 +67,8 @@ import AssignedMemberField from './AssignedMember-Field' import Tooltip from '../Tooltip' import CancelDropDown from './Cancel-Dropdown' import UseSchedulingAPIField from './UseSchedulingAPIField' +import FunChallengeField from './FunChallengeField' +import WiproAllowedField from './WiproAllowedField' import MilestoneField from './Milestone-Field' import DiscussionField from './Discussion-Field' @@ -453,6 +455,9 @@ class ChallengeEditor extends Component { * @param isSub The option from sub field * @param field The challenge field * @param index The index of array + * + * Side effect: when `field` is `typeId` and the selected type is not Marathon Match, + * resets `funChallenge` to `false`. */ onUpdateSelect (option, isSub = false, field = '', index = -1) { if (option) { @@ -460,6 +465,9 @@ class ChallengeEditor extends Component { const newChallenge = { ...oldChallenge } if (!isSub) { newChallenge[field] = option + if (field === 'typeId' && option !== MARATHON_TYPE_ID) { + newChallenge.funChallenge = false + } } else { if (index < 0) { newChallenge[field][option.key] = option.name @@ -786,8 +794,13 @@ class ChallengeEditor extends Component { } checkValidCopilot () { - const copilotFee = _.find(this.state.challenge.prizeSets, p => p.type === PRIZE_SETS_TYPE.COPILOT_PAYMENT, []) - if (copilotFee && parseInt(copilotFee.prizes[0].value) > 0 && !this.state.challenge.copilot) { + const { challenge } = this.state + if (challenge.funChallenge === true) { + return true + } + + const copilotFee = _.find(challenge.prizeSets, p => p.type === PRIZE_SETS_TYPE.COPILOT_PAYMENT, []) + if (copilotFee && parseInt(copilotFee.prizes[0].value) > 0 && !challenge.copilot) { return false } return true @@ -874,16 +887,17 @@ class ChallengeEditor extends Component { isValidChallenge () { const { challenge } = this.state + const isFunChallenge = challenge.funChallenge === true if (this.props.isNew) { const { name, trackId, typeId } = challenge return !!name && !!trackId && !!typeId } - if (!this.isValidChallengePrizes()) { + if (!isFunChallenge && !this.isValidChallengePrizes()) { return false } - if (!this.checkValidCopilot()) { + if (!isFunChallenge && !this.checkValidCopilot()) { return false } @@ -899,9 +913,11 @@ class ChallengeEditor extends Component { 'trackId', 'typeId', 'name', - 'description', - 'prizeSets' + 'description' ] + if (!isFunChallenge) { + requiredFields.push('prizeSets') + } if (isSkillsRequired) { requiredFields.push('skills') } @@ -1076,6 +1092,15 @@ class ChallengeEditor extends Component { }, 500) } + /** + * Collects challenge payload for create/update API calls. + * + * Includes `funChallenge` in picked challenge fields and, when `funChallenge` + * is enabled, omits `prizeSets` from the payload. + * + * @param {string} status challenge status to set in payload + * @returns {Object} cloned challenge payload ready for API submission + */ collectChallengeData (status) { const { isPhaseChange } = this.state const { attachments, metadata } = this.props @@ -1098,7 +1123,9 @@ class ChallengeEditor extends Component { 'discussions', 'task', 'skills', - 'reviewers' + 'reviewers', + 'funChallenge', + 'wiproAllowed' ], this.state.challenge) const isTask = _.find(metadata.challengeTypes, { id: challenge.typeId, isTask: true }) challenge.legacy = _.assign(this.state.challenge.legacy, { @@ -1106,10 +1133,13 @@ class ChallengeEditor extends Component { }) challenge.timelineTemplateId = _.get(this.getCurrentTemplate(), 'id') challenge.projectId = this.props.projectId - challenge.prizeSets = challenge.prizeSets.map(p => { + challenge.prizeSets = (challenge.prizeSets || []).map(p => { const prizes = p.prizes.map(s => ({ ...s, value: convertDollarToInteger(s.value, '$') })) return { ...p, prizes } }) + if (challenge.funChallenge === true) { + delete challenge.prizeSets + } challenge.status = status if (status === CHALLENGE_STATUS.ACTIVE && isTask) { challenge.startDate = moment().format() @@ -1810,6 +1840,8 @@ class ChallengeEditor extends Component { const copilotResources = metadata.members || challengeResources const isDesignChallenge = challenge.trackId === DES_TRACK_ID const isChallengeType = challenge.typeId === CHALLENGE_TYPE_ID + const isMarathonMatch = challenge.typeId === MARATHON_TYPE_ID + const isFunChallenge = challenge.funChallenge === true const showRoundType = isDesignChallenge && isChallengeType const showCheckpointPrizes = challenge.timelineTemplateId === MULTI_ROUND_CHALLENGE_TEMPLATE_ID const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' }) @@ -1932,6 +1964,7 @@ class ChallengeEditor extends Component { {/* remove terms field and use default term */} {false && ()} +
@@ -2025,14 +2058,21 @@ class ChallengeEditor extends Component { token={token} removeAttachment={removeAttachment} />} - - { - showCheckpointPrizes && ( - - ) - } - - + {isMarathonMatch && ( + + )} + {!isFunChallenge && ( + <> + + { + showCheckpointPrizes && ( + + ) + } + + + + )}
{errorContainer} {actionButtons} diff --git a/src/containers/ChallengeEditor/index.js b/src/containers/ChallengeEditor/index.js index 2147fdc9..5cb3a294 100644 --- a/src/containers/ChallengeEditor/index.js +++ b/src/containers/ChallengeEditor/index.js @@ -129,14 +129,28 @@ class ChallengeEditor extends Component { componentWillReceiveProps (nextProps) { const { match } = this.props - const { match: newMatch, loadChallengeDetails, loadResources, loadSubmissions, projectDetail, loggedInUser } = nextProps + const { + match: newMatch, + loadChallengeDetails, + loadResources, + loadSubmissions, + projectDetail, + loggedInUser, + submissionsPerPage + } = nextProps const projectId = _.get(newMatch.params, 'projectId', null) const challengeId = _.get(newMatch.params, 'challengeId', null) if ( _.get(match.params, 'projectId', null) !== projectId || _.get(match.params, 'challengeId', null) !== challengeId ) { - this.fetchChallengeDetails(newMatch, loadChallengeDetails, loadResources, loadSubmissions) + this.fetchChallengeDetails( + newMatch, + loadChallengeDetails, + loadResources, + loadSubmissions, + submissionsPerPage + ) } else { this.setState({ challengeDetails: nextProps.challengeDetails }) } diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index f3edb55a..871f078b 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -150,14 +150,15 @@ class Challenges extends Component { fetchNextProjects } = this.props const { challengeTypes = [] } = metadata + const isActiveProjectLoaded = + reduxProjectInfo && `${reduxProjectInfo.id}` === `${activeProjectId}` + return ( {(dashboard || activeProjectId !== -1 || selfService) && ( } */ -export async function fetchSubmissions (challengeId, pageObj) { - const { page, perPage } = pageObj +export async function fetchSubmissions (challengeId, pageObj = {}) { + const page = _.get(pageObj, 'page', 1) + const perPage = _.get(pageObj, 'perPage', 10) const response = await axiosInstance.get(`${SUBMISSIONS_API_URL}?challengeId=${challengeId}&perPage=${perPage}&page=${page}`) const responseData = _.get(response, 'data', {}) const meta = _.get(responseData, 'meta', {})