diff --git a/config/constants/development.js b/config/constants/development.js index 438ffea1..2fd8dcea 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -27,6 +27,7 @@ module.exports = { SUBMISSIONS_API_URL: `${DEV_API_HOSTNAME}/v6/submissions`, REVIEW_TYPE_API_URL: `${DEV_API_HOSTNAME}/v6/reviewTypes`, SCORECARDS_API_URL: `${DEV_API_HOSTNAME}/v6/scorecards`, + WORKFLOWS_API_URL: `${DEV_API_HOSTNAME}/v6/workflows`, SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, diff --git a/config/constants/production.js b/config/constants/production.js index 7e6411fc..a1ad5d32 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -27,7 +27,8 @@ module.exports = { RESOURCE_ROLES_API_URL: `${PROD_API_HOSTNAME}/v5/resource-roles`, SUBMISSIONS_API_URL: `${PROD_API_HOSTNAME}/v5/submissions`, REVIEW_TYPE_API_URL: `${PROD_API_HOSTNAME}/v5/reviewTypes`, - SCORECARDS_API_URL: `${PROD_API_HOSTNAME}/v5/scorecards`, //update to use v6 + SCORECARDS_API_URL: `${PROD_API_HOSTNAME}/v5/scorecards`, // update to use v6 + WORKFLOWS_API_URL: `${DEV_API_HOSTNAME}/v5/workflows`, // update to use v6 SUBMISSION_REVIEW_APP_URL: `https://submission-review.${DOMAIN}/challenges`, STUDIO_URL: `https://studio.${DOMAIN}`, CONNECT_APP_URL: `https://connect.${DOMAIN}`, diff --git a/src/actions/challenges.js b/src/actions/challenges.js index f644d7fc..74a7252d 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -22,7 +22,8 @@ import { deleteResource as deleteResourceAPI, updateChallengeSkillsApi, fetchDefaultReviewers, - fetchScorecards + fetchScorecards, + fetchWorkflows } from '../services/challenges' import { searchProfilesByUserIds } from '../services/user' import { @@ -817,3 +818,27 @@ export function loadDefaultReviewers (filters = {}) { } } } + +/** + * Load workflows + */ +export function loadWorkflows () { + return async (dispatch) => { + try { + const workflows = await fetchWorkflows() + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'workflows', + metadataValue: workflows || [] + }) + } catch (error) { + console.error('Error loading workflows:', error) + // Return empty array on error to maintain consistency + dispatch({ + type: LOAD_CHALLENGE_METADATA_SUCCESS, + metadataKey: 'workflows', + metadataValue: [] + }) + } + } +} diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index bd91706e..b45e2353 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux' import cn from 'classnames' import { PrimaryButton, OutlineButton } from '../../Buttons' import { REVIEW_OPPORTUNITY_TYPES, VALIDATION_VALUE_TYPE } from '../../../config/constants' -import { loadScorecards, loadDefaultReviewers } from '../../../actions/challenges' +import { loadScorecards, loadDefaultReviewers, loadWorkflows } from '../../../actions/challenges' import styles from './ChallengeReviewer-Field.module.scss' import { convertDollarToInteger, validateValue } from '../../../util/input-check' @@ -15,22 +15,45 @@ class ChallengeReviewerField extends Component { error: null } - // Bind methods this.addReviewer = this.addReviewer.bind(this) this.removeReviewer = this.removeReviewer.bind(this) this.updateReviewer = this.updateReviewer.bind(this) this.renderReviewerForm = this.renderReviewerForm.bind(this) this.handleApplyDefault = this.handleApplyDefault.bind(this) + this.isAIReviewer = this.isAIReviewer.bind(this) + } + + isAIReviewer (reviewer) { + return reviewer && ( + (reviewer.aiWorkflowId && reviewer.aiWorkflowId.trim() !== '') || + (reviewer.isMemberReview === false) + ) } componentDidMount () { this.loadScorecards() this.loadDefaultReviewers() + this.loadWorkflows() + } + + componentDidUpdate (prevProps) { + const { challenge } = this.props + const prevChallenge = prevProps.challenge + + if (challenge && prevChallenge && + (challenge.type !== prevChallenge.type || challenge.track !== prevChallenge.track)) { + this.loadScorecards() + } + + if (challenge && prevChallenge && + (challenge.typeId !== prevChallenge.typeId || challenge.trackId !== prevChallenge.trackId)) { + this.loadDefaultReviewers() + } } loadScorecards () { const { challenge, loadScorecards } = this.props - // Build query parameters for the scorecard API + const filters = {} // Add challenge track if available @@ -38,6 +61,11 @@ class ChallengeReviewerField extends Component { filters.challengeTrack = challenge.track.toUpperCase() } + // Add challenge type if available + if (challenge.type) { + filters.challengeType = challenge.type + } + loadScorecards(filters) } @@ -56,6 +84,11 @@ class ChallengeReviewerField extends Component { }) } + loadWorkflows () { + const { loadWorkflows } = this.props + loadWorkflows() + } + addReviewer () { const { challenge, onUpdateReviewers } = this.props const currentReviewers = challenge.reviewers || [] @@ -69,15 +102,40 @@ class ChallengeReviewerField extends Component { ) const firstReviewPhase = reviewPhases && reviewPhases.length > 0 ? reviewPhases[0] : null + const isAIReviewer = this.isAIReviewer(defaultReviewer) + + // For AI reviewers, get scorecardId from the workflow if available + let scorecardId = '' + if (isAIReviewer) { + const { metadata = {} } = this.props + const { workflows = [] } = metadata + const defaultWorkflowId = defaultReviewer && defaultReviewer.aiWorkflowId + if (defaultWorkflowId) { + const workflow = workflows.find(w => w.id === defaultWorkflowId) + scorecardId = workflow && workflow.scorecardId ? workflow.scorecardId : undefined + } else { + scorecardId = undefined + } + } else { + scorecardId = (defaultReviewer && defaultReviewer.scorecardId) || '' + } + const newReviewer = { - scorecardId: (defaultReviewer && defaultReviewer.scorecardId) || '', - isMemberReview: true, - memberReviewerCount: (defaultReviewer && defaultReviewer.memberReviewerCount) || 1, + scorecardId, + isMemberReview: !isAIReviewer, phaseId: (defaultReviewer && defaultReviewer.phaseId) || (firstReviewPhase ? (firstReviewPhase.id || firstReviewPhase.phaseId) : ''), basePayment: (defaultReviewer && defaultReviewer.basePayment) || '0', incrementalPayment: (defaultReviewer && defaultReviewer.incrementalPayment) || 0, - type: (defaultReviewer && defaultReviewer.opportunityType) || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW, - isAIReviewer: (defaultReviewer && defaultReviewer.isAIReviewer) || false + type: isAIReviewer ? null : ((defaultReviewer && defaultReviewer.opportunityType) || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW) + } + + if (isAIReviewer) { + newReviewer.aiWorkflowId = (defaultReviewer && defaultReviewer.aiWorkflowId) || '' + } + + // Set member-specific fields for member reviewers + if (!isAIReviewer) { + newReviewer.memberReviewerCount = (defaultReviewer && defaultReviewer.memberReviewerCount) || 1 } const updatedReviewers = currentReviewers.concat([newReviewer]) @@ -97,6 +155,18 @@ class ChallengeReviewerField extends Component { const updatedReviewers = currentReviewers.slice() const fieldUpdate = {} fieldUpdate[field] = value + + if (field === 'aiWorkflowId') { + const { metadata = {} } = this.props + const { workflows = [] } = metadata + const workflow = workflows.find(w => w.id === value) + if (workflow && workflow.scorecardId) { + fieldUpdate.scorecardId = workflow.scorecardId + } else { + fieldUpdate.scorecardId = undefined + } + } + updatedReviewers[index] = Object.assign({}, updatedReviewers[index], fieldUpdate) onUpdateReviewers({ field: 'reviewers', value: updatedReviewers }) } @@ -116,29 +186,32 @@ class ChallengeReviewerField extends Component { validateReviewer (reviewer) { const errors = [] - - if (typeof reviewer.isAIReviewer !== 'boolean') { - errors.push('Reviewer type must be specified') - } - - if (!reviewer.scorecardId) { - errors.push('Scorecard is required') + const isAI = this.isAIReviewer(reviewer) + + if (isAI) { + if (!reviewer.aiWorkflowId || reviewer.aiWorkflowId.trim() === '') { + errors.push('AI Workflow is required') + } + } else { + if (!reviewer.scorecardId) { + errors.push('Scorecard is required') + } + + const memberCount = parseInt(reviewer.memberReviewerCount) || 1 + if (memberCount < 1 || !Number.isInteger(memberCount)) { + errors.push('Number of reviewers must be a positive integer') + } + + const basePayment = convertDollarToInteger(reviewer.basePayment || '0', '') + if (basePayment < 0) { + errors.push('Base payment must be non-negative') + } } if (!reviewer.phaseId) { errors.push('Phase is required') } - const memberCount = parseInt(reviewer.memberReviewerCount) || 1 - if (!reviewer.isAIReviewer && (memberCount < 1 || !Number.isInteger(memberCount))) { - errors.push('Number of reviewers must be a positive integer') - } - - const basePayment = convertDollarToInteger(reviewer.basePayment || '0', '') - if (!reviewer.isAIReviewer && (basePayment < 0)) { - errors.push('Base payment must be non-negative') - } - return errors } @@ -151,13 +224,13 @@ class ChallengeReviewerField extends Component { renderReviewerForm (reviewer, index) { const { challenge, metadata = {}, readOnly = false } = this.props - const { scorecards = [] } = metadata + const { scorecards = [], workflows = [] } = metadata const validationErrors = this.validateReviewer(reviewer) return (
- {index > 0 &&

Reviewer {index + 1}

} +

Reviewer Type {index + 1}

{!readOnly && ( {readOnly ? ( - {reviewer.isAIReviewer ? 'AI Reviewer' : 'Member Reviewer'} + {this.isAIReviewer(reviewer) ? 'AI Reviewer' : 'Member Reviewer'} ) : ( this.updateReviewer(index, 'scorecardId', e.target.value)} - > - - {scorecards.map(scorecard => ( - - ))} - - )} -
+ {this.isAIReviewer(reviewer) ? ( +
+ + {readOnly ? ( + + {(() => { + const workflow = workflows.find(w => w.id === reviewer.aiWorkflowId) + return workflow ? workflow.name : 'Not selected' + })()} + + ) : ( + + )} +
+ ) : ( +
+ + {readOnly ? ( + + {(() => { + const scorecard = scorecards.find(s => s.id === reviewer.scorecardId) + return scorecard ? `${scorecard.name} - ${scorecard.type} (${scorecard.challengeTrack}) v${scorecard.version}` : 'Not selected' + })()} + + ) : ( + + )} +
+ )}
@@ -255,9 +376,18 @@ class ChallengeReviewerField extends Component { {challenge.phases && challenge.phases .filter(phase => { - const isReviewPhase = phase.name && phase.name.toLowerCase().includes('review') - const isCurrentlySelected = reviewer.phaseId && ((phase.id === reviewer.phaseId) || (phase.phaseId === reviewer.phaseId)) - return isReviewPhase || isCurrentlySelected + const phaseName = phase.name ? phase.name.toLowerCase() : '' + const isReviewPhase = phaseName.includes('review') + const isSubmissionPhase = phaseName.includes('submission') + const isCurrentlySelected = reviewer.phaseId && ((phase.id === reviewer.phaseId) || (phase.phaseId === reviewer.phaseId)) && !isSubmissionPhase + + // For AI reviewers, allow both review and submission phases + // For member reviewers, only allow review phases even after changing the reviewer type + if (this.isAIReviewer(reviewer)) { + return (isReviewPhase || isSubmissionPhase) || isCurrentlySelected + } else { + return isReviewPhase || isCurrentlySelected + } }) .map(phase => (
- {!reviewer.isAIReviewer && ( + {!this.isAIReviewer(reviewer) && (
@@ -323,36 +453,38 @@ class ChallengeReviewerField extends Component {
)} -
-
- - {readOnly ? ( - - {(() => { - const typeMap = { - [REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW]: 'Regular Review', - [REVIEW_OPPORTUNITY_TYPES.COMPONENT_DEV_REVIEW]: 'Component Dev Review', - [REVIEW_OPPORTUNITY_TYPES.SPEC_REVIEW]: 'Spec Review', - [REVIEW_OPPORTUNITY_TYPES.ITERATIVE_REVIEW]: 'Iterative Review', - [REVIEW_OPPORTUNITY_TYPES.SCENARIOS_REVIEW]: 'Scenarios Review' - } - return typeMap[reviewer.type] || 'Regular Review' - })()} - - ) : ( - - )} + {!this.isAIReviewer(reviewer) && ( +
+
+ + {readOnly ? ( + + {(() => { + const typeMap = { + [REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW]: 'Regular Review', + [REVIEW_OPPORTUNITY_TYPES.COMPONENT_DEV_REVIEW]: 'Component Dev Review', + [REVIEW_OPPORTUNITY_TYPES.SPEC_REVIEW]: 'Spec Review', + [REVIEW_OPPORTUNITY_TYPES.ITERATIVE_REVIEW]: 'Iterative Review', + [REVIEW_OPPORTUNITY_TYPES.SCENARIOS_REVIEW]: 'Scenarios Review' + } + return typeMap[reviewer.type] || 'Regular Review' + })()} + + ) : ( + + )} +
-
+ )}
) } @@ -360,7 +492,7 @@ class ChallengeReviewerField extends Component { render () { const { challenge, metadata = {}, isLoading, readOnly = false } = this.props const { error } = this.state - const { scorecards = [], defaultReviewers = [] } = metadata + const { scorecards = [], defaultReviewers = [], workflows = [] } = metadata const reviewers = challenge.reviewers || [] if (isLoading) { @@ -376,8 +508,7 @@ class ChallengeReviewerField extends Component { ) } - // Only show error if there's a real error, not just missing data - if (error && !scorecards.length && !defaultReviewers.length) { + if (error && !scorecards.length && !defaultReviewers.length && !workflows.length) { return (
@@ -434,15 +565,15 @@ class ChallengeReviewerField extends Component {

Review Summary

Total Member Reviewers: - {reviewers.filter(r => !r.isAIReviewer).reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 0), 0)} + {reviewers.filter(r => !this.isAIReviewer(r)).reduce((sum, r) => sum + (parseInt(r.memberReviewerCount) || 0), 0)}
Total AI Reviewers: - {reviewers.filter(r => r.isAIReviewer).length} + {reviewers.filter(r => this.isAIReviewer(r)).length}
Total Review Cost: - ${reviewers.filter(r => !r.isAIReviewer).reduce((sum, r) => { + ${reviewers.filter(r => !this.isAIReviewer(r)).reduce((sum, r) => { const base = convertDollarToInteger(r.basePayment || '0', '') const count = parseInt(r.memberReviewerCount) || 1 return sum + (base * count) @@ -472,12 +603,14 @@ ChallengeReviewerField.propTypes = { onUpdateReviewers: PropTypes.func.isRequired, metadata: PropTypes.shape({ scorecards: PropTypes.array, - defaultReviewers: PropTypes.array + defaultReviewers: PropTypes.array, + workflows: PropTypes.array }), isLoading: PropTypes.bool, readOnly: PropTypes.bool, loadScorecards: PropTypes.func.isRequired, - loadDefaultReviewers: PropTypes.func.isRequired + loadDefaultReviewers: PropTypes.func.isRequired, + loadWorkflows: PropTypes.func.isRequired } const mapStateToProps = (state) => ({ @@ -487,7 +620,8 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = { loadScorecards, - loadDefaultReviewers + loadDefaultReviewers, + loadWorkflows } export default connect(mapStateToProps, mapDispatchToProps)(ChallengeReviewerField) diff --git a/src/components/ChallengeEditor/ChallengeTotal-Field/index.js b/src/components/ChallengeEditor/ChallengeTotal-Field/index.js index 020ebd4f..63e440d4 100644 --- a/src/components/ChallengeEditor/ChallengeTotal-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeTotal-Field/index.js @@ -17,7 +17,13 @@ const ChallengeTotalField = ({ challenge }) => { let reviewerTotal = 0 if (challenge.reviewers && Array.isArray(challenge.reviewers)) { reviewerTotal = challenge.reviewers - .filter(r => !r.isAIReviewer) + .filter(r => { + const isAI = r && ( + (r.aiWorkflowId && r.aiWorkflowId.trim() !== '') || + (r.isMemberReview === false) + ) + return !isAI + }) .reduce((sum, r) => { const base = convertDollarToInteger(r.basePayment || '0', '') const count = parseInt(r.memberReviewerCount) || 1 diff --git a/src/config/constants.js b/src/config/constants.js index 8955269c..54c041b4 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -240,6 +240,7 @@ export const REVIEW_TYPES = { export const REVIEW_OPPORTUNITY_TYPES = { REGULAR_REVIEW: 'REGULAR_REVIEW', + COMPONENT_DEV_REVIEW: 'COMPONENT_DEV_REVIEW', SPEC_REVIEW: 'SPEC_REVIEW', ITERATIVE_REVIEW: 'ITERATIVE_REVIEW', SCENARIOS_REVIEW: 'SCENARIOS_REVIEW' diff --git a/src/services/challenges.js b/src/services/challenges.js index 1627fc98..84508d37 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -14,6 +14,7 @@ const { SUBMISSIONS_API_URL, REVIEW_TYPE_API_URL, SCORECARDS_API_URL, + WORKFLOWS_API_URL, GROUPS_API_URL, TERMS_API_URL, RESOURCES_API_URL, @@ -333,3 +334,12 @@ export async function fetchDefaultReviewers (filters = {}) { const response = await axiosInstance.get(`${CHALLENGE_API_URL}/default-reviewers?${qs.stringify(filters, { encode: false })}`) return _.get(response, 'data', []) } + +/** + * Api request for fetching workflows + * @returns {Promise<*>} + */ +export async function fetchWorkflows () { + const response = await axiosInstance.get(`${WORKFLOWS_API_URL}`) + return _.get(response, 'data', {}) +}