diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index 294058e27c0ed9a4636a5075bb6301aae1913a1a..728b2e28a08eb1572980a1879a27e4a6e5eb98f5 100644 --- a/packages/component-faraday-selectors/src/index.js +++ b/packages/component-faraday-selectors/src/index.js @@ -1,5 +1,6 @@ import { selectCurrentUser } from 'xpub-selectors' -import { get, has, last, chain, some, isEmpty } from 'lodash' +// eslint-disable-next-line no-unused-vars +import { get, has, last, chain, some, isEmpty, flatten } from 'lodash' export const isHEToManuscript = (state, collectionId = '') => { const { id = '', isAccepted = false } = chain(state) @@ -229,6 +230,34 @@ export const canMakeDecision = (state, collection = {}) => { return isEIC && canMakeDecisionStatuses.includes(status) } +const collectionReviewerReports = state => + chain(state) + .get('fragments', {}) + .map(r => get(r, 'recommendations', [])) + .flatten() + .find(r => r.recommendationType === 'review' && r.submittedOn) + .value() + +const cannotHEMakeRecommendationToPublishStatuses = ['heInvited'] +export const canHEMakeRecommendationToPublish = (state, collection = {}) => { + const status = get(collection, 'status', 'draft') + return ( + !!collectionReviewerReports(state) || + cannotHEMakeRecommendationToPublishStatuses.includes(status) + ) +} + +const canHEOnlyRejectStatuses = [ + 'reviewersInvited', + 'underReview', + 'revisionRequested', +] + +export const canHEOnlyReject = (collection = {}) => { + const { status } = collection + return canHEOnlyRejectStatuses.includes(status) +} + const canEditManuscriptStatuses = ['draft', 'technicalChecks', 'inQA'] export const canEditManuscript = (state, collection = {}, fragment = {}) => { const isAdmin = currentUserIs(state, 'isAdmin') diff --git a/packages/component-faraday-ui/src/contextualBoxes/HERecommendation.js b/packages/component-faraday-ui/src/contextualBoxes/HERecommendation.js index 69f2a7b7f2164e0086292d91dfdc1b458621409a..3eb14eb32d1a156f67e545557886f1366d14b0cf 100644 --- a/packages/component-faraday-ui/src/contextualBoxes/HERecommendation.js +++ b/packages/component-faraday-ui/src/contextualBoxes/HERecommendation.js @@ -46,27 +46,14 @@ const options = [ }, ] -const optionsWhereHECanOnlyReject = [ - 'reviewersInvited', - 'underReview', - 'revisionRequested', -] - const showHEOptions = ({ - collection, - hasReviewerReports, - fragment, - options, - optionsWhereHECanOnlyReject, + canHEMakeRecommendationToPublish, + canHEOnlyReject, }) => { - const { status, fragments } = collection - const { invitations } = fragment - if (optionsWhereHECanOnlyReject.includes(status)) { + if (canHEOnlyReject) { return [options[1]] - } else if (!hasReviewerReports && fragments.length === 1) { + } else if (!canHEMakeRecommendationToPublish) { return tail(options) - } else if (invitations === []) { - return [options[1]] } return options } @@ -86,12 +73,11 @@ const parseFormValues = ({ recommendation, ...rest }) => { } const HERecommendation = ({ - formValues, + canHEMakeRecommendationToPublish, + canHEOnlyReject, handleSubmit, - hasReviewerReports, + formValues, highlight, - collection, - fragment, }) => ( <ContextualBox highlight={highlight} @@ -110,11 +96,8 @@ const HERecommendation = ({ component={input => ( <Menu options={showHEOptions({ - collection, - hasReviewerReports, - fragment, - options, - optionsWhereHECanOnlyReject, + canHEMakeRecommendationToPublish, + canHEOnlyReject, })} {...input} /> diff --git a/packages/component-fixture-manager/src/fixtures/fragments.js b/packages/component-fixture-manager/src/fixtures/fragments.js index 0478b2aa9c53fa0174cb337c15c79e5d44b5bc3d..34004073c406e0772e9ea95b4279de08db844a09 100644 --- a/packages/component-fixture-manager/src/fixtures/fragments.js +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -425,8 +425,6 @@ const fragments = { isCorresponding: false, }, ], - owners: [user.id], - type: 'fragment', }, }, noEditorRecomedationFragment: { @@ -530,8 +528,147 @@ const fragments = { fragments.noInvitesFragment = { ...fragments.fragment1, + recommendations: [], + invites: [], + id: chance.guid(), +} +fragments.noInvitesFragment = { + ...fragments.fragment1, + recommendations: [], invites: [], id: chance.guid(), } +fragments.noInvitesFragment1 = { + ...fragments.fragment, + recommendations: [], + invites: [], + id: chance.guid(), +} +fragments.minorRevisionWithoutReview = { + ...fragments.fragment, + recommendations: [ + { + recommendation: 'minor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditor.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + }, + ], + id: chance.guid(), +} +fragments.minorRevisionWithReview = { + ...fragments.fragment, + recommendations: [ + { + recommendation: 'minor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditor.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + }, + { + recommendation: 'publish', + recommendationType: 'review', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: reviewer1.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + submittedOn: chance.timestamp(), + }, + ], + id: chance.guid(), +} +fragments.majorRevisionWithReview = { + ...fragments.fragment, + recommendations: [ + { + recommendation: 'major', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditor.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + }, + { + recommendation: 'publish', + recommendationType: 'review', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: reviewer1.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + submittedOn: chance.timestamp(), + }, + ], + id: chance.guid(), +} + module.exports = fragments diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index d4a4310b2e39b974037596a603de4ecfac7e7354..70efac8ded420595e62bc16fa271b90a74fb741d 100644 --- a/packages/component-helper-service/src/services/Collection.js +++ b/packages/component-helper-service/src/services/Collection.js @@ -1,3 +1,7 @@ +const { findLast, get } = require('lodash') + +const Fragment = require('./Fragment') + class Collection { constructor({ collection = {} }) { this.collection = collection @@ -103,6 +107,36 @@ class Collection { const [firstName, lastName] = this.collection.handlingEditor.name.split(' ') return lastName || firstName } + + // eslint-disable-next-line class-methods-use-this + hasAtLeastOneReviewReport(fragments) { + return fragments.some(fragment => + new Fragment({ fragment }).hasReviewReport(), + ) + } + + canHEMakeRecommendation(fragments, fragmentHelper) { + if (this.collection.fragments.length === 1) { + return fragmentHelper.hasReviewReport() + } + const previousVersionRecommendations = get( + fragments[fragments.length - 2], + 'recommendations', + [], + ) + + const lastRecommendationByHE = findLast( + previousVersionRecommendations, + recommendation => + recommendation.userId === this.collection.handlingEditor.id && + recommendation.recommendationType === 'editorRecommendation', + ) + if (lastRecommendationByHE.recommendation === 'minor') { + return this.hasAtLeastOneReviewReport(fragments) + } else if (lastRecommendationByHE.recommendation === 'major') { + return fragmentHelper.hasReviewReport() + } + } } module.exports = Collection diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index efb52e5e85b02421b422e85e0eb34a8ee0e16eb7..80c1b743799a457857c17c7a58388e5109ccab2f 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -1,3 +1,4 @@ +/* eslint-disable no-return-await */ const uuid = require('uuid') const { pick, get, set, has, isEmpty, last, findLast } = require('lodash') const config = require('config') @@ -25,7 +26,7 @@ module.exports = models => async (req, res) => { const { collectionId, fragmentId } = req.params - let collection, fragment + let collection, fragment, fragments try { collection = await models.Collection.find(collectionId) @@ -41,6 +42,21 @@ module.exports = models => async (req, res) => { }) } + const collectionHelper = new Collection({ collection }) + + try { + fragments = await Promise.all( + collection.fragments.map( + async fragment => await models.Fragment.find(fragment), + ), + ) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'Item') + fragments = [] + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } const currentUserRecommendations = get( fragment, 'recommendations', @@ -80,7 +96,6 @@ module.exports = models => async (req, res) => { .status(400) .json({ error: 'Cannot write a review on an older version.' }) } - if ( last(collection.fragments) === fragmentId && !isEmpty(currentUserRecommendations) @@ -108,10 +123,10 @@ module.exports = models => async (req, res) => { collection.handlingEditor && collection.handlingEditor.id === req.user ) { - if (!fragmentHelper.hasReviewReport()) { - return res - .status(400) - .json({ error: 'Cannot publish without at least one reviewer report.' }) + if (!collectionHelper.canHEMakeRecommendation(fragments, fragmentHelper)) { + return res.status(400).json({ + error: 'Cannot publish without at least one reviewer report.', + }) } } @@ -128,8 +143,6 @@ module.exports = models => async (req, res) => { newRecommendation.comments = comments || undefined if (recommendationType === 'editorRecommendation') { - const collectionHelper = new Collection({ collection }) - collectionHelper.updateStatusOnRecommendation({ isEditorInChief, recommendation, diff --git a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js index fa1cf243d4793e33a5dcb004499ebb11b1d2d472..512b02f2e63c0dfebd4ea0269530cf5e6c0c0a4c 100644 --- a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js +++ b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js @@ -28,7 +28,7 @@ const reqBody = { ], }, ], - recommendationType: 'review', + recommendationType: 'editorRecommendation', } const path = '../routes/fragmentsRecommendations/post' @@ -80,7 +80,8 @@ describe('Post fragments recommendations route handler', () => { const data = JSON.parse(res._getData()) expect(data.userId).toEqual(reviewer.id) }) - it('should return success when creating a recommendation as a HE', async () => { + + it('should return success when creating a recommendation as a HE when there is a single version with at least one review.', async () => { const { noRecommendationHE } = testFixtures.users const { noEditorRecomedationCollection } = testFixtures.collections const { noEditorRecomedationFragment } = testFixtures.fragments @@ -100,6 +101,148 @@ describe('Post fragments recommendations route handler', () => { const data = JSON.parse(res._getData()) expect(data.userId).toEqual(noRecommendationHE.id) }) + + it('should return an error when creating a recommendation with publish as a HE when there is a single version and there are no reviews.', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + fragment.recommendations = [] + + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + 'Cannot publish without at least one reviewer report.', + ) + }) + + it('should return success when creating a recommendation as a HE after minor revision and we have at least one review on collection.', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { + minorRevisionWithReview, + noInvitesFragment1, + } = testFixtures.fragments + + collection.fragments = [minorRevisionWithReview.id, noInvitesFragment1.id] + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: noInvitesFragment1.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.userId).toEqual(handlingEditor.id) + }) + + it('should return error when creating a recommendation as a HE after minor revision and there are no reviews.', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { + minorRevisionWithoutReview, + noInvitesFragment1, + } = testFixtures.fragments + + collection.fragments = [ + minorRevisionWithoutReview.id, + noInvitesFragment1.id, + ] + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: noInvitesFragment1.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + 'Cannot publish without at least one reviewer report.', + ) + }) + + it('should return success when creating a recommendation as a HE after major revision and there are least one review on fragment.', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { + majorRevisionWithReview, + reviewCompletedFragment, + } = testFixtures.fragments + + reviewCompletedFragment.collectionId = collection.id + collection.fragments = [ + majorRevisionWithReview.id, + reviewCompletedFragment.id, + ] + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: reviewCompletedFragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.userId).toEqual(handlingEditor.id) + }) + + it('should return error when creating a recommendation as a HE after major revision there are no reviews on fragment.', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { + majorRevisionWithReview, + noInvitesFragment1, + } = testFixtures.fragments + + collection.fragments = [majorRevisionWithReview.id, noInvitesFragment1.id] + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: noInvitesFragment1.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + 'Cannot publish without at least one reviewer report.', + ) + }) + it('should return an error when the fragmentId does not match the collectionId', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index d2445c23e6e231fae50c65f5970ae94da0253b39..4953fa278e884980a5bfe28168c62a6a0e97a455 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -30,50 +30,52 @@ const cannotViewReviewersDetails = ['revisionRequested', 'pendingApproval'] const ManuscriptLayout = ({ history, - currentUser, - getSignedUrl, - editorInChief, - handlingEditors, - editorialRecommendations, journal = {}, - collection = {}, fragment = {}, - changeForm, + versions, isFetching, - isFetchingData, - publonsFetching, - fetchingError, + changeForm, formValues, heExpanded, + collection = {}, + currentUser, + getSignedUrl, + shouldReview, + editorInChief, + fetchingError, toggleAssignHE, + isFetchingData, + submitRevision, + inviteReviewer, + isLatestVersion, + publonsFetching, + publonReviewers, + reviewerReports, + handlingEditors, + canHEOnlyReject, toggleHEResponse, heResponseExpanded, + inviteHandlingEditor, + toggleReviewerDetails, + recommendationHandler, toggleReviewerResponse, + reviewerDetailsExpanded, + toggleEditorialComments, + reviewerRecommendations, invitationsWithReviewers, - responseToRevisionRequestExpanded, - publonReviewers, reviewerResponseExpanded, pendingOwnRecommendation, + editorialRecommendations, + responseToRevisionRequest, + editorialCommentsExpanded, + submittedOwnRecommendation, toggleReviewerRecommendations, reviewerRecommendationExpanded, authorResponseToRevisonRequest, toggleResponeToRevisionRequest, - shouldReview, - submittedOwnRecommendation, - reviewerReports, - reviewerRecommendations, - toggleReviewerDetails, - reviewerDetailsExpanded, toggleResponseToRevisionRequest, - editorialCommentsExpanded, - toggleEditorialComments, - submitRevision, - inviteReviewer, - recommendationHandler, - inviteHandlingEditor, - - versions, - isLatestVersion, + canHEMakeRecommendationToPublish, + responseToRevisionRequestExpanded, }) => ( <Root pb={30}> {!isEmpty(collection) && !isEmpty(fragment) ? ( @@ -100,7 +102,6 @@ const ManuscriptLayout = ({ revokeInvitation={inviteHandlingEditor.revokeHE} versions={versions} /> - <ManuscriptMetadata currentUser={currentUser} fragment={fragment} @@ -229,10 +230,11 @@ const ManuscriptLayout = ({ {isLatestVersion && get(currentUser, 'permissions.canMakeHERecommendation', false) && ( <HERecommendation - collection={collection} + canHEMakeRecommendationToPublish={ + canHEMakeRecommendationToPublish + } + canHEOnlyReject={canHEOnlyReject} formValues={get(formValues, 'editorialRecommendation', {})} - fragment={fragment} - hasReviewerReports={reviewerRecommendations.length > 0} highlight={reviewerRecommendations.length > 0} modalKey="heRecommendation" onRecommendationSubmit={ diff --git a/packages/component-manuscript/src/components/ManuscriptPage.js b/packages/component-manuscript/src/components/ManuscriptPage.js index 8e7d53639af1c42d026b288996a04e2f9f957d9c..89b5dadefcc611d56684ad1b442b326f2f3870ab 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -36,6 +36,7 @@ import { currentUserIs, canViewReports, canMakeRevision, + canHEOnlyReject, canMakeDecision, isHEToManuscript, canSubmitRevision, @@ -56,6 +57,7 @@ import { getOwnPendingRecommendation, getOwnSubmittedRecommendation, canAuthorViewEditorialComments, + canHEMakeRecommendationToPublish, getFragmentReviewerRecommendations, getInvitationsWithReviewersForFragment, } from 'pubsweet-component-faraday-selectors' @@ -206,6 +208,11 @@ export default compose( ), }, }, + canHEMakeRecommendationToPublish: canHEMakeRecommendationToPublish( + state, + collection, + ), + canHEOnlyReject: canHEOnlyReject(collection), isFetchingData: { editorsFetching: selectFetching(state), publonsFetching: isFetching,