diff --git a/src/shared/actions/page/review-opportunity-details.js b/src/shared/actions/page/review-opportunity-details.js
index 6b9c75ffa5..7bc3a15208 100644
--- a/src/shared/actions/page/review-opportunity-details.js
+++ b/src/shared/actions/page/review-opportunity-details.js
@@ -3,6 +3,10 @@
*/
import _ from 'lodash';
import { createActions } from 'redux-actions';
+import { logger } from 'topcoder-react-lib';
+
+import { getDetails } from '../../services/reviewOpportunities';
+
/* Holds valid values for the tab state. */
export const TABS = {
@@ -10,9 +14,39 @@ export const TABS = {
CHALLENGE_SPEC: 'CHALLENGE_SPEC',
};
+/**
+ * @static
+ * @desc Creates an action that signals beginning of loading the review
+ * opportunity details.
+ * @return {Action}
+ */
+function getDetailsInit() {}
+
+/**
+ * @static
+ * @desc Creates an action that gets details of a review opportunity for
+ * the specified challenge.
+ * @param {Number} challengeId The ID of the challenge (not the opportunity id)
+ * @param {Number} opportunityId The ID of the review opportunity
+ * @param {String} tokenV3=null Optional. Topcoder auth token v3.
+ * @return {Action}
+ */
+function getDetailsDone(challengeId, opportunityId) {
+ return getDetails(challengeId, opportunityId)
+ .then(details => ({ details }))
+ .catch((error) => {
+ if (error.status !== 401) {
+ logger.error('Error Getting Review Opportunity Details', error.content || error);
+ }
+ return Promise.reject(error.status);
+ });
+}
+
export default createActions({
PAGE: {
REVIEW_OPPORTUNITY_DETAILS: {
+ GET_DETAILS_INIT: getDetailsInit,
+ GET_DETAILS_DONE: getDetailsDone,
SELECT_TAB: _.identity,
SET_ROLES: _.identity,
TOGGLE_APPLY_MODAL: _.identity,
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/index.jsx
index 214a25f1e4..f60f0f30e1 100644
--- a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/index.jsx
+++ b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/index.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import './styles.scss';
const FailedToLoad = () => (
-
+
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss
index 1aa3016218..7c13c67016 100644
--- a/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss
+++ b/src/shared/components/ReviewOpportunityDetailsPage/FailedToLoad/styles.scss
@@ -1,6 +1,6 @@
@import "~styles/mixins";
-.comtainer {
+.container {
background: $tc-gray-neutral-dark;
width: 100%;
display: flex;
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx
index c0a4db58c7..a258fa12a9 100644
--- a/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx
+++ b/src/shared/components/ReviewOpportunityDetailsPage/Header/PhaseList/index.jsx
@@ -22,9 +22,9 @@ const { formatDuration } = time;
* @return {Object} The rendered React element
*/
const renderPhase = phase => (
-
+
- {phase.type}
+ {phase.name}
diff --git a/src/shared/components/ReviewOpportunityDetailsPage/index.jsx b/src/shared/components/ReviewOpportunityDetailsPage/index.jsx
index a322f105e9..0e3339ead4 100644
--- a/src/shared/components/ReviewOpportunityDetailsPage/index.jsx
+++ b/src/shared/components/ReviewOpportunityDetailsPage/index.jsx
@@ -42,7 +42,7 @@ const ReviewOpportunityDetailsPage = ({
- {details.challenge.title}
+ {details.challenge.name}
diff --git a/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx b/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx
index d880792b3b..1f7d0eba0a 100644
--- a/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx
+++ b/src/shared/components/challenge-listing/ReviewOpportunityCard/index.jsx
@@ -78,7 +78,7 @@ function ReviewOpportunityCard({
) /* END - DISABLED UNTIL REVIEW OPPORTUNITY RECEIVE UPDATE TO API V5 */ }
{challenge.title}
@@ -156,7 +156,7 @@ function ReviewOpportunityCard({
diff --git a/src/shared/containers/ReviewOpportunityDetails.jsx b/src/shared/containers/ReviewOpportunityDetails.jsx
index bb40d2f79e..5f01554ae2 100644
--- a/src/shared/containers/ReviewOpportunityDetails.jsx
+++ b/src/shared/containers/ReviewOpportunityDetails.jsx
@@ -8,13 +8,14 @@ import React from 'react';
import PT from 'prop-types';
import { connect } from 'react-redux';
-import { actions, errors } from 'topcoder-react-lib';
+import { errors } from 'topcoder-react-lib';
import LoadingIndicator from 'components/LoadingIndicator';
import { activeRoleIds } from 'utils/reviewOpportunities';
import pageActions from 'actions/page/review-opportunity-details';
import ReviewOpportunityDetailsPage from 'components/ReviewOpportunityDetailsPage';
import FailedToLoad from 'components/ReviewOpportunityDetailsPage/FailedToLoad';
import termsActions from 'actions/terms';
+import { goToLogin } from 'utils/tc';
const { fireErrorMessage } = errors;
@@ -25,6 +26,7 @@ class ReviewOpportunityDetailsContainer extends React.Component {
componentDidMount() {
const {
challengeId,
+ opportunityId,
details,
isLoadingDetails,
loadDetails,
@@ -32,19 +34,37 @@ class ReviewOpportunityDetailsContainer extends React.Component {
} = this.props;
if (!isLoadingDetails && !details) {
- loadDetails(challengeId, tokenV3);
+ loadDetails(challengeId, opportunityId, tokenV3);
} else if (details.challenge.id !== challengeId) {
- loadDetails(challengeId, tokenV3);
+ loadDetails(challengeId, opportunityId, tokenV3);
}
}
handleOnHeaderApply() {
const {
+ isLoggedIn,
+ isReviewer,
openTermsModal,
terms,
termsFailure,
toggleApplyModal,
} = this.props;
+
+ if (!isLoggedIn) {
+ goToLogin('community-app-main');
+ return;
+ }
+
+ if (!isReviewer) {
+ fireErrorMessage(
+ 'Permission Required',
+
+ You must have a reviewer role to apply for this review opportunity.
+ ,
+ );
+ return;
+ }
+
if (termsFailure) {
fireErrorMessage('Error Getting Terms Details', '');
return;
@@ -130,6 +150,8 @@ ReviewOpportunityDetailsContainer.defaultProps = {
termsFailure: false,
phasesExpanded: false,
tokenV3: null,
+ isLoggedIn: false,
+ isReviewer: false,
};
/**
@@ -140,6 +162,7 @@ ReviewOpportunityDetailsContainer.propTypes = {
authError: PT.bool,
cancelApplications: PT.func.isRequired,
challengeId: PT.string.isRequired,
+ opportunityId: PT.string.isRequired,
details: PT.shape(),
handle: PT.string.isRequired,
isLoadingDetails: PT.bool,
@@ -157,6 +180,8 @@ ReviewOpportunityDetailsContainer.propTypes = {
toggleRole: PT.func.isRequired,
onPhaseExpand: PT.func.isRequired,
tokenV3: PT.string,
+ isLoggedIn: PT.bool,
+ isReviewer: PT.bool,
};
/**
@@ -169,12 +194,14 @@ ReviewOpportunityDetailsContainer.propTypes = {
const mapStateToProps = (state, ownProps) => {
const api = state.reviewOpportunity;
const page = state.page.reviewOpportunityDetails;
+ const queryParams = new URLSearchParams(ownProps.location.search);
const { terms } = state;
return {
authError: api.authError,
applyModalOpened: page.applyModalOpened,
challengeId: String(ownProps.match.params.challengeId),
- details: api.details,
+ opportunityId: queryParams.get('opportunityId'),
+ details: page.details,
handle: state.auth.user ? state.auth.user.handle : '',
isLoadingDetails: api.isLoadingDetails,
phasesExpanded: page.phasesExpanded,
@@ -184,6 +211,8 @@ const mapStateToProps = (state, ownProps) => {
terms: terms.terms,
termsFailure: terms.getTermsFailure,
tokenV3: state.auth.tokenV3,
+ isLoggedIn: Boolean(state.auth.user),
+ isReviewer: _.includes(state.auth.userRoles || [], 'REVIEWER'),
};
};
@@ -194,16 +223,16 @@ const mapStateToProps = (state, ownProps) => {
* @return {Object}
*/
function mapDispatchToProps(dispatch) {
- const api = actions.reviewOpportunity;
+ const api = pageActions.page.reviewOpportunityDetails;
const page = pageActions.page.reviewOpportunityDetails;
return {
cancelApplications: (challengeId, roleIds, tokenV3) => {
dispatch(api.cancelApplicationsInit());
dispatch(api.cancelApplicationsDone(challengeId, roleIds, tokenV3));
},
- loadDetails: (challengeId, tokenV3) => {
+ loadDetails: (challengeId, opportunityId, tokenV3) => {
dispatch(api.getDetailsInit());
- dispatch(api.getDetailsDone(challengeId, tokenV3));
+ dispatch(api.getDetailsDone(challengeId, opportunityId, tokenV3));
},
onPhaseExpand: () => dispatch(page.togglePhasesExpand()),
openTermsModal: () => {
diff --git a/src/shared/reducers/page/review-opportunity-details.js b/src/shared/reducers/page/review-opportunity-details.js
index 39b8127e44..fe9fa08a49 100644
--- a/src/shared/reducers/page/review-opportunity-details.js
+++ b/src/shared/reducers/page/review-opportunity-details.js
@@ -3,6 +3,52 @@ import { handleActions } from 'redux-actions';
import actions, { TABS } from 'actions/page/review-opportunity-details';
+/**
+ * Generates a list of unique terms ids required for the open review roles
+ * with an agreed field
+ *
+ * @param {Object} details Review Opportuny details from API
+ * @return {Array} List of unique terms
+ */
+function buildRequiredTermsList(details) {
+ const roles = details.payments.map(payment => payment.role);
+
+ const requiredTerms = _.uniqBy(
+ details.challenge.terms
+ // Sometimes roles such as Primary Reviewer have no directly equal
+ // terms entry. Include the plain Reviewer terms when present as a back-up.
+ .filter(term => term.role === 'Reviewer' || _.includes(roles, term.role))
+ .map(term => _.pick(term, ['id', 'agreed', 'title'])),
+ term => term.id,
+ );
+
+ return requiredTerms || [];
+}
+
+
+/**
+ * Handles REVIEW_OPPORTUNITY/GET__DETAILS_DONE action.
+ * @param {Object} state
+ * @param {Object} action Payload will be JSON from api call
+ * @return {Object} New state
+ */
+function onGetDetailsDone(state, { payload, error }) {
+ if (error) {
+ return {
+ ...state,
+ authError: true,
+ isLoadingDetails: false,
+ };
+ }
+
+ return {
+ ...state,
+ details: payload.details,
+ isLoadingDetails: false,
+ requiredTerms: buildRequiredTermsList(payload.details),
+ };
+}
+
/**
* Creates a new reducer.
* @param {Object} state Optional. Initial state.
@@ -11,6 +57,8 @@ import actions, { TABS } from 'actions/page/review-opportunity-details';
function create(defaultState = {}) {
const a = actions.page.reviewOpportunityDetails;
return handleActions({
+ [a.getDetailsInit]: state => ({ ...state, isLoadingDetails: true }),
+ [a.getDetailsDone]: onGetDetailsDone,
[a.selectTab]: (state, { payload }) => ({ ...state, selectedTab: payload }),
[a.setRoles]: (state, { payload }) => ({ ...state, selectedRoles: payload }),
[a.toggleApplyModal]: state => ({ ...state, applyModalOpened: !state.applyModalOpened }),
diff --git a/src/shared/services/reviewOpportunities.js b/src/shared/services/reviewOpportunities.js
index ca7748c02f..3374cb02c1 100644
--- a/src/shared/services/reviewOpportunities.js
+++ b/src/shared/services/reviewOpportunities.js
@@ -1,4 +1,6 @@
import { config } from 'topcoder-react-utils';
+import _ from 'lodash';
+
const v6ApiUrl = config.API.V6;
@@ -24,3 +26,53 @@ export default async function getReviewOpportunities(page, pageSize) {
return res.json();
}
+
+/**
+ * Sync the fields of V3 and V5 for front-end to process successfully
+ * @param challenge - challenge to normalize
+ */
+function normalizeChallengePhases(challenge) {
+ return {
+ ...challenge,
+ phases: _.map(challenge.phases, phase => ({
+ ...phase,
+ scheduledStartDate: phase.scheduledStartTime,
+ scheduledEndDate: phase.scheduledEndTime,
+ })),
+ };
+}
+
+
+/**
+ * Gets the details of the review opportunity for the corresponding challenge
+ * @param {Number} challengeId The ID of the challenge (not the opportunity id)
+ * @return {Object} The combined data of the review opportunity and challenge details
+ */
+export async function getDetails(challengeId, opportunityId) {
+ const getReviewOpportunityUrl = new URL(`${v6ApiUrl}/review-opportunities/${opportunityId}`);
+ const getChallengeUrl = new URL(`${v6ApiUrl}/challenges/${challengeId}`);
+
+ try {
+ const [opportunityRes, challengeRes] = await Promise.all([
+ fetch(getReviewOpportunityUrl.toString(), { method: 'GET' }),
+ fetch(getChallengeUrl.toString(), { method: 'GET' }),
+ ]);
+
+ if (!opportunityRes.ok) {
+ throw new Error(`Failed to load review opportunity: ${opportunityRes.statusText}`);
+ }
+ if (!challengeRes.ok) {
+ throw new Error(`Failed to load challenge details: ${challengeRes.statusText}`);
+ }
+
+ const opportunityData = await opportunityRes.json();
+ const challengeData = await challengeRes.json();
+
+ return {
+ ...opportunityData.result.content,
+ challenge: normalizeChallengePhases(challengeData),
+ };
+ } catch (err) {
+ return Promise.reject(err);
+ }
+}