Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/shared/actions/page/review-opportunity-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,50 @@
*/
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 = {
APPLICATIONS: 'APPLICATIONS',
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import './styles.scss';

const FailedToLoad = () => (
<div styleName="comtainer">
<div styleName="container">
<div styleName="failed-to-load">
<h2>
<span />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@import "~styles/mixins";

.comtainer {
.container {
background: $tc-gray-neutral-dark;
width: 100%;
display: flex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ const { formatDuration } = time;
* @return {Object} The rendered React element
*/
const renderPhase = phase => (
<div key={`phase-${phase.type}`} styleName={moment().isBetween(phase.scheduledStartDate, phase.scheduledEndDate) ? 'active-phase' : 'inactive-phase'}>
<div key={`phase-${phase.name}`} styleName={moment().isBetween(phase.scheduledStartDate, phase.scheduledEndDate) ? 'active-phase' : 'inactive-phase'}>
<div styleName="type">
{phase.type}
{phase.name}
</div>
<div styleName="date">
<strong>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const ReviewOpportunityDetailsPage = ({

<div styleName="header">
<h1 styleName="challenge-title">
{details.challenge.title}
{details.challenge.name}
</h1>
<div styleName="tags">
<div styleName="review-opportunity-tag">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function ReviewOpportunityCard({
) /* END - DISABLED UNTIL REVIEW OPPORTUNITY RECEIVE UPDATE TO API V5 */ }
<div styleName="challenge-details">
<Link
to={`${challengesUrl}/${challenge.id}`}
to={`${challengesUrl}/${opportunity.challengeId}`}
>
{challenge.title}
</Link>
Expand Down Expand Up @@ -156,7 +156,7 @@ function ReviewOpportunityCard({
</Tooltip>
</div>
<Link
to={`/challenges/${challenge.legacyId || challenge.id}/review-opportunities`}
to={`/challenges/${opportunity.challengeId}/review-opportunities?opportunityId=${opportunity.id}`}
styleName="register-button"
>
<span>
Expand Down
43 changes: 36 additions & 7 deletions src/shared/containers/ReviewOpportunityDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,26 +26,45 @@ class ReviewOpportunityDetailsContainer extends React.Component {
componentDidMount() {
const {
challengeId,
opportunityId,
details,
isLoadingDetails,
loadDetails,
tokenV3,
} = 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',
<span>
You must have a reviewer role to apply for this review opportunity.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the link to 'Learn how to become a reviewer' may lead to confusion for users who are not aware of how to obtain a reviewer role. Consider providing guidance or a reference to where users can find more information about becoming a reviewer.

</span>,
);
return;
}

if (termsFailure) {
fireErrorMessage('Error Getting Terms Details', '');
return;
Expand Down Expand Up @@ -130,6 +150,8 @@ ReviewOpportunityDetailsContainer.defaultProps = {
termsFailure: false,
phasesExpanded: false,
tokenV3: null,
isLoggedIn: false,
isReviewer: false,
};

/**
Expand All @@ -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,
Expand All @@ -157,6 +180,8 @@ ReviewOpportunityDetailsContainer.propTypes = {
toggleRole: PT.func.isRequired,
onPhaseExpand: PT.func.isRequired,
tokenV3: PT.string,
isLoggedIn: PT.bool,
isReviewer: PT.bool,
};

/**
Expand All @@ -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,
Expand All @@ -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'),
};
};

Expand All @@ -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: () => {
Expand Down
48 changes: 48 additions & 0 deletions src/shared/reducers/page/review-opportunity-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 }),
Expand Down
52 changes: 52 additions & 0 deletions src/shared/services/reviewOpportunities.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { config } from 'topcoder-react-utils';
import _ from 'lodash';


const v6ApiUrl = config.API.V6;

Expand All @@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function getDetails is documented to return a Promise that resolves to the API response in JSON, but it actually returns a normalized object combining opportunity and challenge data. Consider updating the documentation to reflect the actual return structure.

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);
}
}