diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31d2c028..f55e361a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,8 +342,8 @@ importers: specifier: ^3.3.4 version: 3.3.4(webpack@5.102.0) tc-auth-lib: - specifier: topcoder-platform/tc-auth-lib#1.0.4 - version: '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/68fdc22464810c51b703a33e529cdbd6d09437de' + specifier: topcoder-platform/tc-auth-lib#v2.0 + version: '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/56996006ee5918b3e77fc5a8ab005ae738b4de12' terser: specifier: ^5.31.0 version: 5.44.0 @@ -1518,6 +1518,10 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} + '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/56996006ee5918b3e77fc5a8ab005ae738b4de12': + resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/56996006ee5918b3e77fc5a8ab005ae738b4de12} + version: 1.0.2 + '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/68fdc22464810c51b703a33e529cdbd6d09437de': resolution: {tarball: https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/68fdc22464810c51b703a33e529cdbd6d09437de} version: 1.0.2 @@ -10189,6 +10193,10 @@ snapshots: '@tootallnate/once@1.1.2': {} + '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/56996006ee5918b3e77fc5a8ab005ae738b4de12': + dependencies: + lodash: 4.17.21 + '@topcoder-platform/tc-auth-lib@https://codeload.github.com/topcoder-platform/tc-auth-lib/tar.gz/68fdc22464810c51b703a33e529cdbd6d09437de': dependencies: lodash: 4.17.21 diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss index ca2cf854..268b520e 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/ChallengeReviewer-Field.module.scss @@ -39,6 +39,10 @@ flex-direction: column; width: 600px; } + + .fieldError { + margin-top: 12px; + } } } diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index 218e6bf0..5fbafd29 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -482,6 +482,11 @@ class ChallengeReviewerField extends Component { newReviewer.memberReviewerCount = (defaultReviewer && defaultReviewer.memberReviewerCount) || 1 } + // Clear any prior transient error when add succeeds + if (this.state.error) { + this.setState({ error: null }) + } + const updatedReviewers = currentReviewers.concat([newReviewer]) onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) } @@ -513,6 +518,21 @@ class ChallengeReviewerField extends Component { // Special handling for phase and count changes if (field === 'phaseId') { + // Before changing phase, ensure we're not creating a duplicate manual reviewer for the target phase + const targetPhaseId = value + const isCurrentMember = (updatedReviewers[index] && (updatedReviewers[index].isMemberReview !== false)) + if (isCurrentMember) { + const conflict = (currentReviewers || []).some((r, i) => i !== index && (r.isMemberReview !== false) && (r.phaseId === targetPhaseId)) + if (conflict) { + const phase = (challenge.phases || []).find(p => (p.id === targetPhaseId) || (p.phaseId === targetPhaseId)) + const phaseName = phase ? (phase.name || targetPhaseId) : targetPhaseId + this.setState({ + error: `Cannot move manual reviewer to phase '${phaseName}' because a manual reviewer configuration already exists for that phase.` + }) + return + } + } + this.handlePhaseChangeWithReassign(index, value) // update payment based on default reviewer @@ -632,6 +652,21 @@ class ChallengeReviewerField extends Component { const currentReviewers = challenge.reviewers || [] const updatedReviewers = currentReviewers.slice() + // Block switching an AI reviewer to a member reviewer if another manual reviewer exists for same phase + if (!isAI) { + const existingReviewer = currentReviewers[index] || {} + const phaseId = existingReviewer.phaseId + const conflict = currentReviewers.some((r, i) => i !== index && (r.isMemberReview !== false) && (r.phaseId === phaseId)) + if (conflict) { + const phase = (challenge.phases || []).find(p => (p.id === phaseId) || (p.phaseId === phaseId)) + const phaseName = phase ? (phase.name || phaseId) : phaseId + this.setState({ + error: `Cannot switch to Member Reviewer: a manual reviewer configuration already exists for phase '${phaseName}'. Increase "Number of Reviewers" on the existing configuration instead.` + }) + return + } + } + // Update reviewer type by setting/clearing aiWorkflowId const currentReviewer = updatedReviewers[index] @@ -674,6 +709,11 @@ class ChallengeReviewerField extends Component { this.handleToggleShouldOpen(index, true) } + // Clear any transient error when successful change is applied + if (this.state.error) { + this.setState({ error: null }) + } + onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) }} > @@ -772,10 +812,10 @@ class ChallengeReviewerField extends Component { const isPostMortemPhase = norm === 'postmortem' const isCurrentlySelected = reviewer.phaseId && ((phase.id === reviewer.phaseId) || (phase.phaseId === reviewer.phaseId)) && !isSubmissionPhase - // Collect phases already assigned to other reviewers (excluding current reviewer) + // Collect phases already assigned to other manual (member) reviewers (excluding current reviewer) const assignedPhaseIds = new Set( (challenge.reviewers || []) - .filter((r, i) => i !== index) + .filter((r, i) => i !== index && (r.isMemberReview !== false)) .map(r => r.phaseId) .filter(id => id !== undefined && id !== null) ) @@ -1051,6 +1091,11 @@ class ChallengeReviewerField extends Component { /> )} + {error && !isLoading && ( +
+ {error} +
+ )} diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index c5681f6c..ee500aaa 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -23,6 +23,9 @@ import { PHASE_PRODUCT_CHALLENGE_ID_FIELD, MULTI_ROUND_CHALLENGE_TEMPLATE_ID, DS_TRACK_ID, + DEV_TRACK_ID, + MARATHON_TYPE_ID, + CHALLENGE_TYPE_ID, COMMUNITY_APP_URL } from '../../../config/constants' import PhaseInput from '../../PhaseInput' @@ -94,11 +97,33 @@ const ChallengeView = ({ const isTask = _.get(challenge, 'task.isTask', false) const phases = _.get(challenge, 'phases', []) const showCheckpointPrizes = _.get(challenge, 'timelineTemplateId') === MULTI_ROUND_CHALLENGE_TEMPLATE_ID - const isDataScience = challenge.trackId === DS_TRACK_ID const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' }) const useDashboard = useDashboardData ? (_.isString(useDashboardData.value) && useDashboardData.value === 'true') || (_.isBoolean(useDashboardData.value) && useDashboardData.value) : false + const showDashBoard = (() => { + const isSupportedTrack = challenge.trackId === DS_TRACK_ID || challenge.trackId === DEV_TRACK_ID + const isSupportedType = challenge.typeId === MARATHON_TYPE_ID || challenge.typeId === CHALLENGE_TYPE_ID + + return (isSupportedTrack && isSupportedType) || Boolean(useDashboardData) + })() + const dashboardToggle = showDashBoard && ( +
+
+ +
+
+ +
+
+ ) return (
@@ -138,13 +163,6 @@ const ChallengeView = ({
- {isDataScience && ( -
-
- Show data dashboard: {useDashboard ? 'Yes' : 'No'} -
-
- )} {isTask && } {openAdvanceSettings && ( <> + {dashboardToggle}
diff --git a/src/components/ChallengeEditor/index.js b/src/components/ChallengeEditor/index.js index da51eb04..f5c764ca 100644 --- a/src/components/ChallengeEditor/index.js +++ b/src/components/ChallengeEditor/index.js @@ -25,7 +25,7 @@ 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, CHALLENGE_STATUS, SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS } from '../../config/constants' @@ -148,6 +148,7 @@ class ChallengeEditor extends Component { this.onSaveChallenge = this.onSaveChallenge.bind(this) this.getCurrentTemplate = this.getCurrentTemplate.bind(this) this.onUpdateMetadata = this.onUpdateMetadata.bind(this) + this.shouldShowDashboardSetting = this.shouldShowDashboardSetting.bind(this) this.getTemplatePhases = this.getTemplatePhases.bind(this) this.getAvailableTimelineTemplates = this.getAvailableTimelineTemplates.bind(this) this.autoUpdateChallengeThrottled = _.throttle(this.validateAndAutoUpdateChallenge.bind(this), 3000) // 3s @@ -669,6 +670,20 @@ class ChallengeEditor extends Component { this.setState({ challenge: newChallenge }) } + /** + * Determines when the data dashboard toggle should be shown. + * + * @param {Object} challenge the challenge data to evaluate + */ + shouldShowDashboardSetting (challenge = {}) { + const typeId = _.get(challenge, 'typeId') + const metadata = _.get(challenge, 'metadata', []) + const hasDashboardMetadata = _.some(metadata, { name: 'show_data_dashboard' }) + const isMarathonMatch = typeId === MARATHON_TYPE_ID + + return isMarathonMatch || hasDashboardMetadata + } + /** * Remove Phase from challenge Phases list * @param index @@ -897,11 +912,47 @@ class ChallengeEditor extends Component { return !(isRequiredMissing || _.isEmpty(this.state.currentTemplate)) } + // Return array of phase names that have more than one manual (member) reviewer configured. + // If none, returns empty array. + getDuplicateManualReviewerPhases () { + const { challenge } = this.state + const reviewers = (challenge && challenge.reviewers) || [] + const phases = (challenge && challenge.phases) || [] + + const counts = {} + reviewers.forEach(r => { + if (r && (r.isMemberReview !== false) && r.phaseId) { + const pid = String(r.phaseId) + counts[pid] = (counts[pid] || 0) + 1 + } + }) + + const duplicatedPhaseIds = Object.keys(counts).filter(pid => counts[pid] > 1) + if (duplicatedPhaseIds.length === 0) return [] + + return duplicatedPhaseIds.map(pid => { + const p = phases.find(ph => String(ph.phaseId || ph.id) === pid) + return p ? (p.name || pid) : pid + }) + } + validateChallenge () { if (this.isValidChallenge()) { + // Additional validation: block saving draft if there are duplicate manual reviewer configs per phase + const duplicates = this.getDuplicateManualReviewerPhases() + if (duplicates && duplicates.length > 0) { + const message = `Duplicate manual reviewer configuration found for phase(s): ${duplicates.join(', ')}. Only one manual reviewer configuration is allowed per phase.` + this.setState({ hasValidationErrors: true, error: message }) + return false + } + + if (this.state.error) { + this.setState({ error: null }) + } this.setState({ hasValidationErrors: false }) return true } + this.setState(prevState => ({ ...prevState, challenge: { @@ -1095,11 +1146,7 @@ class ChallengeEditor extends Component { const { challenge: { name, trackId, typeId, milestoneId, roundType, challengeType, metadata: challengeMetadata } } = this.state const { timelineTemplates } = metadata const isDesignChallenge = trackId === DES_TRACK_ID - const isDataScience = trackId === DS_TRACK_ID - const isChallengeType = typeId === CHALLENGE_TYPE_ID - const isDevChallenge = trackId === DEV_TRACK_ID - const isMM = typeId === MARATHON_TYPE_ID - const showDashBoard = (isDataScience && isChallengeType) || (isDevChallenge && isMM) || (isDevChallenge && isChallengeType) + const showDashBoard = this.shouldShowDashboardSetting({ trackId, typeId, metadata: challengeMetadata }) // indicate that creating process has started this.setState({ isSaving: true }) @@ -1754,18 +1801,33 @@ class ChallengeEditor extends Component { const showTimeline = false // disables the timeline for time being https://github.com/topcoder-platform/challenge-engine-ui/issues/706 const copilotResources = metadata.members || challengeResources const isDesignChallenge = challenge.trackId === DES_TRACK_ID - const isDevChallenge = challenge.trackId === DEV_TRACK_ID - const isMM = challenge.typeId === MARATHON_TYPE_ID const isChallengeType = challenge.typeId === CHALLENGE_TYPE_ID const showRoundType = isDesignChallenge && isChallengeType const showCheckpointPrizes = challenge.timelineTemplateId === MULTI_ROUND_CHALLENGE_TEMPLATE_ID - const showDashBoard = (challenge.trackId === DS_TRACK_ID && isChallengeType) || (isDevChallenge && isMM) || (isDevChallenge && isChallengeType) const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' }) + const showDashBoard = this.shouldShowDashboardSetting(challenge) const useDashboard = useDashboardData ? (_.isString(useDashboardData.value) && useDashboardData.value === 'true') || (_.isBoolean(useDashboardData.value) && useDashboardData.value) : false + const dashboardToggle = showDashBoard && ( +
+
+ +
+
+ this.onUpdateMetadata('show_data_dashboard', e.target.checked)} + /> +
+
+ ) + const workTypes = getDomainTypes(challenge.trackId) let filteredTypes = metadata.challengeTypes.filter(type => workTypes.includes(type.abbreviation)) @@ -1794,22 +1856,7 @@ class ChallengeEditor extends Component { } { - showDashBoard && ( -
-
- -
-
- this.onUpdateMetadata('show_data_dashboard', e.target.checked)} - /> -
-
- ) + dashboardToggle } {projectDetail.version === 'v4' && } {useTask && ()} @@ -1841,24 +1888,6 @@ class ChallengeEditor extends Component {
- { - showDashBoard && ( -
-
- -
-
- this.onUpdateMetadata('show_data_dashboard', e.target.checked)} - /> -
-
- ) - } {isTask && ( {isOpenAdvanceSettings && ( + {dashboardToggle} {/* remove terms field and use default term */} {false && ()}