diff --git a/packages/component-email-templating/src/templates/partials/invButtons.hbs b/packages/component-email-templating/src/templates/partials/invButtons.hbs index 7611dc03e966d4e9146cda5bd9b6a535b17ace3d..8deceffee7a2080bd626f746fc7959d815da5a67 100644 --- a/packages/component-email-templating/src/templates/partials/invButtons.hbs +++ b/packages/component-email-templating/src/templates/partials/invButtons.hbs @@ -12,7 +12,7 @@ <td width="300.000px" valign="top" style="padding: 0px 0px 0px 0px;border-collapse: collapse;" > <![endif]--> - <table width="300.000" style="width:300.000px;border-spacing:0;border-collapse:collapse;margin:0px 0px 0px 0px;" + <table width="300.000" style="width:'50%';border-spacing:0;border-collapse:collapse;margin:0px 0px 0px 0px;" cellpadding="0" cellspacing="0" align="left" border="0" bgcolor="" class="column column-0 of-2 empty"> <tr> @@ -21,7 +21,7 @@ role="module" style="table-layout:fixed" width="100%"> <tbody> <tr> - <td align="center" class="outer-td" style="padding:0px 0px 0px 50px"> + <td align="center" class="outer-td padding-decline"> <table border="0" cellPadding="0" cellSpacing="0" class="button-css__deep-table___2OZyb wrapper-mobile" style="text-align:center"> <tbody> @@ -47,7 +47,7 @@ <td width="300.000px" valign="top" style="padding: 0px 0px 0px 0px;border-collapse: collapse;" > <![endif]--> - <table width="300.000" style="width:300.000px;border-spacing:0;border-collapse:collapse;margin:0px 0px 0px 0px;" + <table width="300.000" style="width:'50%';border-spacing:0;border-collapse:collapse;margin:0px 0px 0px 0px;" cellpadding="0" cellspacing="0" align="left" border="0" bgcolor="" class="column column-1 of-2 empty"> <tr> @@ -56,7 +56,7 @@ role="module" style="table-layout:fixed" width="100%"> <tbody> <tr> - <td align="center" class="outer-td" style="padding:0px 50px 0px 0px"> + <td align="center" class="outer-td padding-agree"> <table border="0" cellPadding="0" cellSpacing="0" class="button-css__deep-table___2OZyb wrapper-mobile" style="text-align:center"> <tbody> diff --git a/packages/component-email-templating/src/templates/partials/invHeader.hbs b/packages/component-email-templating/src/templates/partials/invHeader.hbs index d01a40ad4f0645427c966da91a011dbf8ea226a1..473d7b913b24bea5163c4e73aada31056acf8fe3 100644 --- a/packages/component-email-templating/src/templates/partials/invHeader.hbs +++ b/packages/component-email-templating/src/templates/partials/invHeader.hbs @@ -76,6 +76,14 @@ text-decoration: none; } + .padding-decline { + padding:0px 0px 0px 50px; + } + + .padding-agree { + padding:0px 50px 0px 0px; + } + @media screen and (max-width:480px) { .preheader .rightColumnContent, @@ -126,6 +134,14 @@ margin-left: 0 !important; margin-right: 0 !important; } + + .padding-decline { + padding: 0px; + } + + .padding-agree { + padding: 10px 0px 0px 0px; + } } </style> <!--user entered Head Start--> diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index 728b2e28a08eb1572980a1879a27e4a6e5eb98f5..1493dcaf07f232dc17d2ec03e4ea9e9090719b44 100644 --- a/packages/component-faraday-selectors/src/index.js +++ b/packages/component-faraday-selectors/src/index.js @@ -1,6 +1,5 @@ import { selectCurrentUser } from 'xpub-selectors' -// eslint-disable-next-line no-unused-vars -import { get, has, last, chain, some, isEmpty, flatten } from 'lodash' +import { get, has, last, chain, some, isEmpty, slice, find } from 'lodash' export const isHEToManuscript = (state, collectionId = '') => { const { id = '', isAccepted = false } = chain(state) @@ -49,6 +48,29 @@ export const canInviteReviewers = (state, collection = {}) => { return isAccepted && (userId === heId || isAdminEiC) } +const canViewContextualBoxOnOldVersionStatuses = [ + 'submitted', + 'heInvited', + 'heAssigned', +] +const canViewContextualBoxOnOldVersion = (collection, fragmentId) => { + const fragments = get(collection, 'fragments', []) + const oldVersions = slice(fragments, 0, fragments.length - 1) + const isOldVersion = !!find(oldVersions, fragment => fragment === fragmentId) + return ( + isOldVersion && + canViewContextualBoxOnOldVersionStatuses.includes( + get(collection, 'status', 'draft'), + ) + ) +} + +const canHEViewContextualBoxOnOldVersion = (collection, fragmentId) => { + const fragments = get(collection, 'fragments', []) + const oldVersions = slice(fragments, 0, fragments.length - 1) + const isOldVersion = !!find(oldVersions, fragment => fragment === fragmentId) + return isOldVersion && get(collection, 'status', 'draft') === 'heInvited' +} const cannotViewReviewersDetails = [ 'draft', 'technicalChecks', @@ -67,12 +89,14 @@ export const canViewReviewersDetails = (state, collection = {}) => { return canViewReports(state, get(collection, 'id', '')) } -const authorCanViewReportsDetailsStatuses = [ +const authorAndReviewersCanViewReportsDetailsStatuses = [ 'revisionRequested', + 'underReview', 'pendingApproval', 'rejected', 'accepted', 'reviewCompleted', + 'reviewersInvited', 'inQa', ] @@ -83,9 +107,27 @@ export const authorCanViewReportsDetails = ( ) => { const isAuthor = currentUserIsAuthor(state, fragmentId) return ( - authorCanViewReportsDetailsStatuses.includes( + isAuthor && + (authorAndReviewersCanViewReportsDetailsStatuses.includes( get(collection, 'status', 'draft'), - ) && isAuthor + ) || + canViewContextualBoxOnOldVersion(collection, fragmentId)) + ) +} + +export const reviewersCanViewReviewerReports = ( + state, + collection = {}, + fragmentId, +) => { + const isReviewer = currentUserIsReviewer(state, fragmentId) + const reviewerReports = getFragmentReviewerRecommendations(state, fragmentId) + return ( + isReviewer && + authorAndReviewersCanViewReportsDetailsStatuses.includes( + get(collection, 'status', 'draft'), + ) && + reviewerReports.length > 0 ) } @@ -125,6 +167,7 @@ const canReviewerViewEditorialCommentsStatuses = [ 'reviewCompleted', 'pendingApproval', 'revisionRequested', + 'reviewersInvited', ] export const canReviewerViewEditorialComments = ( state, @@ -176,8 +219,13 @@ export const canViewEditorialComments = ( state, fragmentId, ) + const isHE = currentUserIs(state, 'isHE') + const canViewEditorialCommentsOnOldVersion = isHE + ? !canHEViewContextualBoxOnOldVersion(collection, fragmentId) + : canViewContextualBoxOnOldVersion(collection, fragmentId) return ( - (canHeViewEditorialComments(state, collection) || + (canViewEditorialCommentsOnOldVersion || + canHeViewEditorialComments(state, collection) || canEICViewEditorialComments(state, collection) || canReviewerViewEditorialComments(state, collection, fragment) || canAuthorViewEditorialComments(state, collection, fragmentId)) && @@ -185,17 +233,22 @@ export const canViewEditorialComments = ( ) } -const cannotViewResponseFromAuthorStatuses = ['reviewersInvited'] export const canViewResponseFromAuthor = (state, collection, fragmentId) => { const authorResponseToRevisonRequest = getFragmentAuthorResponse( state, fragmentId, ) + const canHEViewResponseFromAuthor = + currentUserIs(state, 'isHE') && + get(collection, 'status', 'draft') === 'heInvited' + + const canReviewerViewResponsefromAuthor = + currentUserIsReviewerInPending(state, fragmentId) && + get(collection, 'status', 'draft') === 'reviewersInvited' return ( !isEmpty(authorResponseToRevisonRequest) && - !cannotViewResponseFromAuthorStatuses.includes( - get(collection, 'status', 'draft'), - ) + !canHEViewResponseFromAuthor && + !canReviewerViewResponsefromAuthor ) } @@ -326,6 +379,13 @@ export const pendingReviewerInvitation = (state, fragmentId) => ) .value() +export const currentUserIsReviewerInPending = (state, fragmentId) => { + const currentUser = selectCurrentUser(state) + const invitations = get(state, `fragments.${fragmentId}.invitations`, []) + return !!invitations.find( + i => i.userId === currentUser.id && i.role === 'reviewer' && !i.isAccepted, + ) +} export const currentUserIsReviewer = (state, fragmentId) => { const currentUser = selectCurrentUser(state) const invitations = get(state, `fragments.${fragmentId}.invitations`, []) @@ -496,11 +556,12 @@ export const getVersionOptions = (state, collection = {}) => { export const canReview = (state, collection = {}, fragment = {}) => { const fragmentId = get(fragment, 'id', false) - if (!fragmentId) return false - + const ownRecommendation = getOwnRecommendations(state, fragmentId) const isReviewer = currentUserIsReviewer(state, fragmentId) if (!isReviewer) return false - - return get(collection, 'status', 'draft') === 'underReview' + return ( + get(collection, 'status', 'draft') === 'underReview' && + ownRecommendation.length === 0 + ) } diff --git a/packages/component-faraday-ui/src/ManuscriptCard.js b/packages/component-faraday-ui/src/ManuscriptCard.js index fb471ea96a51267573dcf00e32d8fe422379b18d..17e9e176354403406b6066cf5b09f589a9a93cfa 100644 --- a/packages/component-faraday-ui/src/ManuscriptCard.js +++ b/packages/component-faraday-ui/src/ManuscriptCard.js @@ -76,7 +76,7 @@ const ManuscriptCard = ({ <Row alignItems="center" justify="flex-start" mb={1}> <H4>Handling editor</H4> <Text ml={1} mr={3} whiteSpace="nowrap"> - {get(handlingEditor, 'name', 'Undefined')} + {get(handlingEditor, 'name', 'Unassigned')} </Text> {canViewReports && ( <Fragment> diff --git a/packages/component-faraday-ui/src/PersonInvitation.js b/packages/component-faraday-ui/src/PersonInvitation.js index 5b1da63fab1b7fb1b129b175fce6cd0518f4b146..492d7cbdab15ab168f2a84f8c470105d1b24bb15 100644 --- a/packages/component-faraday-ui/src/PersonInvitation.js +++ b/packages/component-faraday-ui/src/PersonInvitation.js @@ -9,6 +9,7 @@ const PersonInvitation = ({ withName, hasAnswer, isFetching, + isLatestVersion, revokeInvitation, resendInvitation, person: { name, email }, @@ -57,6 +58,29 @@ const PersonInvitation = ({ </OpenModal> </Fragment> )} + {hasAnswer && + isLatestVersion && ( + <Fragment> + <OpenModal + confirmText="Revoke" + isFetching={isFetching} + modalKey={`remove-${id}`} + onConfirm={revokeInvitation} + subtitle="Deleting the handling editor at this moment will also remove all his work." + title="Revoke invitation?" + > + {showModal => ( + <IconButton + icon="x-circle" + iconSize={2} + ml={2} + onClick={showModal} + secondary + /> + )} + </OpenModal> + </Fragment> + )} </Root> ) diff --git a/packages/component-faraday-ui/src/ReviewerReport.js b/packages/component-faraday-ui/src/ReviewerReport.js index 12391694d8b8032f53d43932629ba1f4d20a12aa..fc2c942c5bb4fdee93bfcbd64cc2bb05d768d3f2 100644 --- a/packages/component-faraday-ui/src/ReviewerReport.js +++ b/packages/component-faraday-ui/src/ReviewerReport.js @@ -11,10 +11,11 @@ const ReviewerReport = ({ onPreview, onDownload, reportFile, + currentUser, publicReport, privateReport, reviewerName, - reviewerIndex, + reviewerNumber, recommendation, showOwner = false, report: { submittedOn }, @@ -27,14 +28,12 @@ const ReviewerReport = ({ </Item> <Item justify="flex-end"> - {showOwner && ( - <Fragment> - <Text>{reviewerName}</Text> - <Text customId ml={1} mr={1}> - {`Reviewer ${reviewerIndex}`} - </Text> - </Fragment> - )} + <Fragment> + {showOwner && <Text>{reviewerName}</Text>} + <Text customId ml={1} mr={1}> + {`Reviewer ${reviewerNumber}`} + </Text> + </Fragment> <DateParser timestamp={submittedOn}> {date => <Text>{date}</Text>} </DateParser> @@ -76,20 +75,22 @@ const ReviewerReport = ({ </Root> ) -export default withProps(({ report, journal: { recommendations = [] } }) => ({ - recommendation: get( - recommendations.find(r => r.value === report.recommendation), - 'label', - ), - reportFile: get(report, 'comments.0.files.0'), - publicReport: get(report, 'comments.0.content'), - privateReport: get(report, 'comments.1.content'), - reviewerName: `${get(report, 'reviewer.firstName', '')} ${get( - report, - 'reviewer.lastName', - '', - )}`, -}))(ReviewerReport) +export default withProps( + ({ report, currentUser, journal: { recommendations = [] } }) => ({ + recommendation: get( + recommendations.find(r => r.value === report.recommendation), + 'label', + ), + reportFile: get(report, 'comments.0.files.0'), + publicReport: get(report, 'comments.0.content'), + privateReport: get(report, 'comments.1.content'), + reviewerName: `${get(currentUser, 'firstName', '')} ${get( + currentUser, + 'lastName', + '', + )}`, + }), +)(ReviewerReport) // #region styles const Root = styled.div` diff --git a/packages/component-faraday-ui/src/ReviewerReportAuthor.js b/packages/component-faraday-ui/src/ReviewerReportAuthor.js index 4df5745b8ce86a109bad547466f3fe5ff57f94de..ded36d3ae2e8128002c2bbde7a0292d172d44d66 100644 --- a/packages/component-faraday-ui/src/ReviewerReportAuthor.js +++ b/packages/component-faraday-ui/src/ReviewerReportAuthor.js @@ -21,7 +21,7 @@ const ReviewerReportAuthor = ({ downloadFile, publicReport, reviewerName, - reviewerIndex, + reviewerNumber, recommendation, showOwner = false, report: { submittedOn }, @@ -38,7 +38,7 @@ const ReviewerReportAuthor = ({ </Row> )} <Text customId ml={1} mr={1} whiteSpace="nowrap"> - {`Reviewer ${reviewerIndex}`} + {`Reviewer ${reviewerNumber}`} </Text> <DateParser timestamp={submittedOn}> {date => <Text>{date}</Text>} @@ -78,7 +78,7 @@ export default compose( 'reviewer.lastName', '', )}`, - reviewerIndex: get(report, 'reviewerIndex', ''), + reviewerNumber: get(report, 'reviewerNumber', ''), })), )(ReviewerReportAuthor) diff --git a/packages/component-faraday-ui/src/ReviewerReportAuthor.md b/packages/component-faraday-ui/src/ReviewerReportAuthor.md index 48fdf8f4faefd2db6af22906b08d22899c94edaa..0ee98012afcf5a17ddccc438eecce6524d3f195b 100644 --- a/packages/component-faraday-ui/src/ReviewerReportAuthor.md +++ b/packages/component-faraday-ui/src/ReviewerReportAuthor.md @@ -29,7 +29,7 @@ const report = { submittedOn: 1538053600624, recommendation: 'publish', recommendationType: 'review', - reviewerIndex: 1 + reviewerNumber: 1 } const journal = { diff --git a/packages/component-faraday-ui/src/ReviewersTable.js b/packages/component-faraday-ui/src/ReviewersTable.js index 9f9e2aaa75309afd25084e94a65cbcf65e2c646b..e2197d20c406eeff827b6b4051f48062d7e7493e 100644 --- a/packages/component-faraday-ui/src/ReviewersTable.js +++ b/packages/component-faraday-ui/src/ReviewersTable.js @@ -41,9 +41,9 @@ const ReviewersTable = ({ invitation, 'person.lastName', )}`}</Text> - {invitation.isAccepted && ( + {invitation.reviewerNumber && ( <Text customId ml={1}> - {renderAcceptedLabel(index)} + Reviewer {invitation.reviewerNumber} </Text> )} </td> @@ -102,12 +102,7 @@ export default compose( withProps(({ invitations = [] }) => ({ invitations: orderBy(invitations, orderInvitations), })), - withProps(({ invitations = [] }) => ({ - firstAccepted: invitations.findIndex(i => i.hasAnswer && i.isAccepted), - })), withHandlers({ - renderAcceptedLabel: ({ firstAccepted, invitations }) => index => - `Reviewer ${index - firstAccepted + 1}`, getInvitationStatus: () => ({ hasAnswer, isAccepted }) => { if (!hasAnswer) return 'PENDING' if (isAccepted) return 'ACCEPTED' diff --git a/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.js b/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.js index 5518b3ebfa9c3803816c871f4f8b32b717c468d3..696e8a4e405ca71b5e6844cfedf81994863060a2 100644 --- a/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.js +++ b/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.js @@ -1,7 +1,14 @@ import React from 'react' import { withProps, compose } from 'recompose' +import { get } from 'lodash' -import { ContextualBox, ReviewerReportAuthor, Row, Text } from '../' +import { + ContextualBox, + ReviewerReportAuthor, + Row, + Text, + indexReviewers, +} from '../' const SubmittedReportsNumberForAuthorReviews = ({ reports }) => ( <Row fitContent justify="flex-end"> @@ -16,12 +23,13 @@ const SubmittedReportsNumberForAuthorReviews = ({ reports }) => ( ) const AuthorReviews = ({ - invitations, + token, journal, reports, fragment, - token, + invitations, getSignedUrl, + reviewerReports, }) => reports.length > 0 && ( <ContextualBox @@ -43,4 +51,24 @@ const AuthorReviews = ({ </ContextualBox> ) -export default compose(withProps())(AuthorReviews) +export default compose( + withProps( + ({ + invitations = [], + publonReviewers = [], + reviewerReports = [], + currentUser, + }) => ({ + token: get(currentUser, 'token', ''), + publonReviewers, + invitations: invitations.map(i => ({ + ...i, + review: reviewerReports.find(r => r.userId === i.userId), + })), + reports: indexReviewers( + reviewerReports.filter(r => r.submittedOn), + invitations, + ), + }), + ), +)(AuthorReviews) diff --git a/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.md b/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.md index aa15139b436a8238511b82742f9202d74516f9ce..6bb32f81d1495f66987b964ce3451a619b57d2ea 100644 --- a/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.md +++ b/packages/component-faraday-ui/src/contextualBoxes/AuthorReviews.md @@ -28,7 +28,7 @@ const reports = [ submittedOn: 1539339580826, recommendation: 'minor', recommendationType: 'review', - reviewerIndex: 1, + reviewerNumber: 1, }, { id: '21258b47-aba5-4597-926e-765458c4fda2', @@ -45,7 +45,7 @@ const reports = [ submittedOn: 1539689169611, recommendation: 'publish', recommendationType: 'review', - reviewerIndex: 2, + reviewerNumber: 2, }, ] diff --git a/packages/component-faraday-ui/src/contextualBoxes/ReviewerDetails.js b/packages/component-faraday-ui/src/contextualBoxes/ReviewerDetails.js index d8a41b821abe0639bbb6f9cfbe6ffe17b9f75189..987aa4a775f7eed363cfcfe0793c5e4baeb1468c 100644 --- a/packages/component-faraday-ui/src/contextualBoxes/ReviewerDetails.js +++ b/packages/component-faraday-ui/src/contextualBoxes/ReviewerDetails.js @@ -13,6 +13,7 @@ import { ContextualBox, ReviewersTable, PublonsTable, + indexReviewers, ReviewerReport, InviteReviewers, ReviewerBreakdown, @@ -118,14 +119,14 @@ const ReviewerDetails = ({ {reports.length === 0 && ( <Text align="center">No reports submitted yet.</Text> )} - {reports.map((report, index) => ( + {reports.map(report => ( <ReviewerReport journal={journal} key={report.id} onDownload={downloadFile} onPreview={previewFile} report={report} - reviewerIndex={index + 1} + reviewerNumber={report.reviewerNumber} showOwner /> ))} @@ -154,7 +155,10 @@ export default compose( ...i, review: reviewerReports.find(r => r.userId === i.userId), })), - reports: reviewerReports.filter(r => r.submittedOn), + reports: indexReviewers( + reviewerReports.filter(r => r.submittedOn), + invitations, + ), }), ), withProps(({ currentUser }) => ({ diff --git a/packages/component-faraday-ui/src/helpers/utils.js b/packages/component-faraday-ui/src/helpers/utils.js index 898fd3ebc62282763a1331fd359f9d8da85a732c..8b798e34e7b73272c922ab6201019b5b4b221fb9 100644 --- a/packages/component-faraday-ui/src/helpers/utils.js +++ b/packages/component-faraday-ui/src/helpers/utils.js @@ -1,4 +1,4 @@ -import { get, chain } from 'lodash' +import { get, chain, find } from 'lodash' export const handleError = fn => e => { fn(get(JSON.parse(e.response), 'error', 'Oops! Something went wrong!')) @@ -10,3 +10,14 @@ export const getReportComments = ({ report, isPublic = false }) => .find(c => c.public === isPublic) .get('content') .value() + +export const indexReviewers = (reports = [], invitations = []) => { + reports.forEach(report => { + report.reviewerNumber = get( + find(invitations, ['userId', report.userId]), + 'reviewerNumber', + 0, + ) + }) + return reports +} diff --git a/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptHeader.js b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptHeader.js index 699edec06e90cfc92695a9cbec10bc2ca8e81d70..296bebafcfc99f9f765f677a90d97d74f630b3e1 100644 --- a/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptHeader.js +++ b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptHeader.js @@ -1,5 +1,5 @@ import React, { Fragment } from 'react' -import { get, chain } from 'lodash' +import { get, chain, isEmpty } from 'lodash' import { H2, H4, DateParser, Button } from '@pubsweet/ui' import { compose, @@ -121,6 +121,7 @@ export default compose( revokeInvitation, pendingInvitation = {}, handlingEditors = [], + isLatestVersion, currentUser: { permissions: { canAssignHE }, id: currentUserId, @@ -128,31 +129,45 @@ export default compose( editorInChief, }, collection: { handlingEditor }, + collection, currentUser, }) => () => { if (pendingInvitation.userId === currentUserId) { return <Text ml={1}>Invited</Text> } - if (pendingInvitation.userId && (admin || editorInChief)) { + const invitedHeId = + get(pendingInvitation, 'userId', false) || + get(heInvitation, 'userId', false) + + if (invitedHeId && (admin || editorInChief)) { const person = chain(handlingEditors) - .filter(he => he.id === pendingInvitation.userId) + .filter(he => he.id === invitedHeId) .map(he => ({ ...he, name: `${he.firstName} ${he.lastName}` })) .first() .value() + let invitedHe = {} + if (get(pendingInvitation, 'userId', false)) { + invitedHe = pendingInvitation + } else if (get(heInvitation, 'userId', false)) { + invitedHe = heInvitation + } return ( <PersonInvitation isFetching={isFetching} + isLatestVersion={isLatestVersion} ml={1} withName - {...pendingInvitation} + {...invitedHe} onResend={resendInvitation} onRevoke={revokeInvitation} person={person} /> ) } - + if (!isEmpty(pendingInvitation)) { + return <Text ml={1}>{handlingEditor.name}</Text> + } if (heInvitation) { return <Text ml={1}>{handlingEditor.name}</Text> } @@ -170,7 +185,7 @@ export default compose( </Button> ) } - return <Text ml={1}>Assigned</Text> + return <Text ml={1}>Unassigned</Text> }, }), setDisplayName('ManuscriptHeader'), diff --git a/packages/component-fixture-manager/src/fixtures/collectionIDs.js b/packages/component-fixture-manager/src/fixtures/collectionIDs.js index e66bb71a232758a7de0746033ba2493745157250..a964c2c498c452599e68dd3457a24eac4c0a3b49 100644 --- a/packages/component-fixture-manager/src/fixtures/collectionIDs.js +++ b/packages/component-fixture-manager/src/fixtures/collectionIDs.js @@ -7,5 +7,6 @@ module.exports = { collectionReviewCompletedID: chance.guid(), collectionNoInvitesID: chance.guid(), twoVersionsCollectionId: chance.guid(), + oneReviewedFragmentCollectionID: chance.guid(), noEditorRecomedationCollectionID: chance.guid(), } diff --git a/packages/component-fixture-manager/src/fixtures/collections.js b/packages/component-fixture-manager/src/fixtures/collections.js index 7d1df81e4f6379f9bb4d21bc243a2ed97a9e9e2d..0fbff658ca84fc9266a31674461679e769a1ff13 100644 --- a/packages/component-fixture-manager/src/fixtures/collections.js +++ b/packages/component-fixture-manager/src/fixtures/collections.js @@ -17,6 +17,7 @@ const { collectionReviewCompletedID, collectionNoInvitesID, twoVersionsCollectionId, + oneReviewedFragmentCollectionID, noEditorRecomedationCollectionID, } = require('./collectionIDs') @@ -30,6 +31,7 @@ const collections = { fragments: [fragment.id], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [fragment]), invitations: [ { id: chance.guid(), @@ -73,6 +75,7 @@ const collections = { fragments: [fragment.id], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [fragment]), invitations: [ { id: chance.guid(), @@ -115,6 +118,7 @@ const collections = { fragments: [fragment1.id, noInvitesFragment.id], owners: [user.id], save: jest.fn(() => collections.collection2), + getFragments: jest.fn(() => [fragment1, noInvitesFragment]), invitations: [ { id: chance.guid(), @@ -159,6 +163,7 @@ const collections = { created: chance.timestamp(), customId: '0000001', fragments: [reviewCompletedFragment.id], + getFragments: jest.fn(() => [reviewCompletedFragment]), invitations: [ { id: chance.guid(), @@ -189,6 +194,7 @@ const collections = { fragments: [fragment.id, reviewCompletedFragment.id], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [fragment, reviewCompletedFragment]), invitations: [ { id: chance.guid(), @@ -219,6 +225,7 @@ const collections = { fragments: [], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => []), customId: chance.natural({ min: 999999, max: 9999999 }), }, noEditorRecomedationCollection: { @@ -228,6 +235,7 @@ const collections = { fragments: [noEditorRecomedationFragment.id], owners: [user.id], save: jest.fn(() => collections.noEditorRecomedationCollection), + getFragments: jest.fn(() => [noEditorRecomedationFragment]), invitations: [ { id: chance.guid(), @@ -263,6 +271,37 @@ const collections = { }, status: 'reviewCompleted', }, + oneReviewedFragmentCollection: { + id: oneReviewedFragmentCollectionID, + title: chance.sentence(), + type: 'collection', + fragments: [reviewCompletedFragment.id, noInvitesFragment.id], + owners: [user.id], + save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [reviewCompletedFragment, noInvitesFragment]), + invitations: [ + { + id: chance.guid(), + role: 'handlingEditor', + hasAnswer: true, + isAccepted: false, + userId: handlingEditor.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + ], + handlingEditor: { + id: handlingEditor.id, + hasAnswer: false, + isAccepted: false, + email: handlingEditor.email, + invitedOn: chance.timestamp(), + respondedOn: null, + name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, + }, + status: 'revisionRequested', + customId: chance.natural({ min: 999999, max: 9999999 }), + }, } module.exports = collections diff --git a/packages/component-fixture-manager/src/fixtures/fragments.js b/packages/component-fixture-manager/src/fixtures/fragments.js index 15e4cb3fa981f4316d1d686a6dc2414d783ed89b..337c0d943c7e6fd87e47fd3e31ff3458c0fb567d 100644 --- a/packages/component-fixture-manager/src/fixtures/fragments.js +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -340,6 +340,7 @@ const fragments = { invitedOn: chance.timestamp(), isAccepted: true, respondedOn: chance.timestamp(), + reviewerNumber: 2, }, { id: chance.guid(), diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index 6e05d9a584f61eddc5a63a7ad309ac81bea1edb6..b0789f05aa93601db62053ce8f5f18382b944973 100644 --- a/packages/component-helper-service/src/services/Collection.js +++ b/packages/component-helper-service/src/services/Collection.js @@ -1,4 +1,4 @@ -const { findLast, get } = require('lodash') +const { findLast, isEmpty, maxBy, get, flatMap } = require('lodash') const Fragment = require('./Fragment') @@ -109,6 +109,27 @@ class Collection { return lastName || firstName } + async getReviewerNumber({ userId }) { + const allCollectionFragments = await this.collection.getFragments() + const allCollectionInvitations = flatMap( + allCollectionFragments, + fragment => fragment.invitations, + ) + const allNumberedInvitationsForUser = allCollectionInvitations + .filter(invite => invite.userId === userId) + .filter(invite => invite.reviewerNumber) + + if (isEmpty(allNumberedInvitationsForUser)) { + const maxReviewerNumber = get( + maxBy(allCollectionInvitations, 'reviewerNumber'), + 'reviewerNumber', + 0, + ) + return maxReviewerNumber + 1 + } + return allNumberedInvitationsForUser[0].reviewerNumber + } + // eslint-disable-next-line class-methods-use-this hasAtLeastOneReviewReport(fragments) { return fragments.some(fragment => @@ -126,17 +147,19 @@ class Collection { [], ) - const lastRecommendationByHE = findLast( + const lastEditorRecommendation = findLast( previousVersionRecommendations, recommendation => - recommendation.userId === this.collection.handlingEditor.id && recommendation.recommendationType === 'editorRecommendation', ) - if (lastRecommendationByHE.recommendation === 'minor') { + + if (lastEditorRecommendation.recommendation === 'minor') { return this.hasAtLeastOneReviewReport(fragments) - } else if (lastRecommendationByHE.recommendation === 'major') { + } else if (lastEditorRecommendation.recommendation === 'major') { return fragmentHelper.hasReviewReport() } + + return false } async getAllFragments({ FragmentModel }) { diff --git a/packages/component-helper-service/src/tests/collection.test.js b/packages/component-helper-service/src/tests/collection.test.js index 41c9f0ed6481079f790df50fc83584af62f2d1c4..167153af8c03fc7149c36a9e8c149de351c0f7db 100644 --- a/packages/component-helper-service/src/tests/collection.test.js +++ b/packages/component-helper-service/src/tests/collection.test.js @@ -10,11 +10,65 @@ const { Collection, Fragment } = require('../Helper') describe('Collection helper', () => { let testFixtures = {} let models + beforeEach(() => { testFixtures = cloneDeep(fixtures) models = Model.build(testFixtures) }) + describe('getReviewerNumber', () => { + it('should assign reviewer number 1 on invitation if no other reviewer numbers exist', async () => { + const { collection } = testFixtures.collections + const { reviewer } = testFixtures.users + const collectionHelper = new Collection({ collection }) + + const reviewerNumber = await collectionHelper.getReviewerNumber({ + userId: reviewer.id, + }) + + expect(reviewerNumber).toBe(1) + }) + it('should assign next reviewer number on invitation if another reviewer numbers exist', async () => { + const { collectionReviewCompleted } = testFixtures.collections + const { reviewer } = testFixtures.users + const collectionHelper = new Collection({ + collection: collectionReviewCompleted, + }) + + const reviewerNumber = await collectionHelper.getReviewerNumber({ + userId: reviewer.id, + }) + + expect(reviewerNumber).toBe(3) + }) + it('should keep reviewer number across fragment versions', async () => { + const { oneReviewedFragmentCollection } = testFixtures.collections + const { answerReviewer } = testFixtures.users + const collectionHelper = new Collection({ + collection: oneReviewedFragmentCollection, + }) + + const reviewerNumber = await collectionHelper.getReviewerNumber({ + userId: answerReviewer.id, + }) + + expect(reviewerNumber).toBe(2) + }) + it('should assign next reviewer number across fragment versions', async () => { + const { oneReviewedFragmentCollection } = testFixtures.collections + const { reviewer } = testFixtures.users + const collectionHelper = new Collection({ + collection: oneReviewedFragmentCollection, + }) + + const reviewerNumber = await collectionHelper.getReviewerNumber({ + userId: reviewer.id, + }) + + expect(reviewerNumber).toBe(3) + }) + }) + describe('hasAtLeastOneReviewReport', () => { it('should return true if collection has at least one report from reviewers.', async () => { const { collection } = testFixtures.collections diff --git a/packages/component-invite/src/CollectionsInvitations.js b/packages/component-invite/src/CollectionsInvitations.js index 2f80dd1b70e3089a7ab7f9068a4e12dbb84985e5..0b72a54da398154f0b1e82b5f78601d57cf86064 100644 --- a/packages/component-invite/src/CollectionsInvitations.js +++ b/packages/component-invite/src/CollectionsInvitations.js @@ -39,7 +39,7 @@ const CollectionsInvitations = app => { require(`${routePath}/post`)(app.locals.models), ) /** - * @api {delete} /api/collections/:collectionId/invitations/:invitationId Delete invitation + * @api {delete} /api/collections/:collectionId/invitations/:invitationId Delete invitation (or revoke HE if invitation is accepted) * @apiGroup CollectionsInvitations * @apiParam {collectionId} collectionId Collection id * @apiParam {invitationId} invitationId Invitation id diff --git a/packages/component-invite/src/routes/collectionsInvitations/delete.js b/packages/component-invite/src/routes/collectionsInvitations/delete.js index 6b3b6764bc654b1f05ea8b4402645649abf7243a..8442dea8cc79be967061c9846127f4d2d34846d9 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/delete.js +++ b/packages/component-invite/src/routes/collectionsInvitations/delete.js @@ -1,8 +1,17 @@ +const config = require('config') + const { Team, services, authsome: authsomeHelper, } = require('pubsweet-component-helper-service') +const { + deleteFilesS3, +} = require('pubsweet-component-mts-package/src/PackageManager') + +const { last, get, chain, difference } = require('lodash') + +const s3Config = get(config, 'pubsweet-component-aws-s3', {}) const notifications = require('./emails/notifications') @@ -56,13 +65,88 @@ module.exports = models => async (req, res) => { user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) await user.save() - notifications.sendInvitedHEEmail({ - models, - collection, - invitedHE: user, - isCanceled: true, - baseUrl: services.getBaseUrl(req), - }) + if (invitation.hasAnswer && invitation.isAccepted) { + const FragmentModel = models.Fragment + const fragment = await FragmentModel.find( + last(get(collection, 'fragments', [])), + ) + + const fragmentId = fragment.id + const teamHelperForFragment = new Team({ + TeamModel: models.Team, + collectionId, + fragmentId, + }) + + const teams = await teamHelperForFragment.getTeams('fragment') + const reviewerTeam = teams.find( + team => team.object.id === fragmentId && team.group === 'reviewer', + ) + if (reviewerTeam) { + reviewerTeam.delete() + } + + const fileKeys = [] + fragment.recommendations && + fragment.recommendations.forEach(recommendation => { + recommendation.comments.forEach(comment => { + comment.files && + comment.files.forEach(file => { + fileKeys.push(file.id) + }) + }) + }) + + const revision = get(fragment, 'revision', false) + if (revision) { + const fragmentFilesIds = chain(get(fragment, 'files', [])) + .flatMap(item => item) + .map(item => item.id) + .value() + const revisionFilesIds = chain(get(fragment, 'revision.files', [])) + .flatMap(item => item) + .map(item => item.id) + .value() + const revisionFileIds = difference(revisionFilesIds, fragmentFilesIds) + fileKeys.concat(revisionFileIds) + } + if (fileKeys.length > 1) { + await deleteFilesS3({ fileKeys, s3Config }) + } + + let shouldAuthorBeNotified + if (fragment.invitations.length > 0) { + shouldAuthorBeNotified = true + } + + fragment.invitations = [] + fragment.recommendations = [] + fragment.revision && delete fragment.revision + await fragment.save() + + notifications.notifyInvitedHEWhenRemoved({ + models, + collection, + invitedHE: user, + baseUrl: services.getBaseUrl(req), + }) + + if (shouldAuthorBeNotified) { + notifications.notifyAuthorWhenHERemoved({ + models, + collection, + baseUrl: services.getBaseUrl(req), + }) + } + } else { + notifications.sendInvitedHEEmail({ + models, + collection, + invitedHE: user, + isCanceled: true, + baseUrl: services.getBaseUrl(req), + }) + } return res.status(200).json({}) } catch (e) { diff --git a/packages/component-invite/src/routes/collectionsInvitations/emails/emailCopy.js b/packages/component-invite/src/routes/collectionsInvitations/emails/emailCopy.js index c7c665749f3f3bb53a1743c6843753186201620d..54db6a8357ffc883df3a8c9c2123bd6ab8452003 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/emails/emailCopy.js +++ b/packages/component-invite/src/routes/collectionsInvitations/emails/emailCopy.js @@ -1,6 +1,7 @@ const config = require('config') const staffEmail = config.get('journal.staffEmail') +const journalName = config.get('journal.name') const getEmailCopy = ({ emailType, titleText, targetUserName, comments }) => { let paragraph @@ -34,6 +35,22 @@ const getEmailCopy = ({ emailType, titleText, targetUserName, comments }) => { paragraph = `${targetUserName} has removed you from the role of Handling Editor for ${titleText}.<br/><br/> The manuscript will no longer appear in your dashboard. Please contact ${staffEmail} if you have any questions about this change.` break + case 'author-he-removed': + hasIntro = true + hasLink = false + hasSignature = true + paragraph = `The handling editor of your manuscript "${titleText}" had to be replaced. This may cause some delays in the peer review process.<br/><br/> + If you have questions please email them to ${staffEmail}.<br/><br/> + Thank you for your submission to ${journalName}.` + break + case 'he-he-removed': + hasIntro = true + hasLink = false + hasSignature = true + paragraph = `The Editor in Chief removed you from the manuscript "${titleText}".<br/><br/> + If you have any questions regarding this action, please let us know at ${staffEmail}.<br/><br/> + Thank you for reviewing ${journalName}.` + break default: throw new Error(`The ${emailType} email type is not defined.`) } diff --git a/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js b/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js index 2b85380e873f1a686ed362c33c0b991eabac3aef..10de02e83f68a525e6cf39f1702290dae90c316b 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js +++ b/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js @@ -35,9 +35,7 @@ module.exports = { } ${submittingAuthor.lastName}` const userHelper = new User({ UserModel }) - const eics = await userHelper.getEditorsInChief() - const eic = eics[0] - const eicName = `${eic.firstName} ${eic.lastName}` + const eicName = await userHelper.getEiCName() const { customId } = collection const { paragraph, ...bodyProps } = getEmailCopy({ @@ -73,6 +71,92 @@ module.exports = { return email.sendEmail() }, + notifyAuthorWhenHERemoved: async ({ + baseUrl, + collection, + models: { User: UserModel, Fragment: FragmentModel }, + }) => { + const fragmentId = last(collection.fragments) + const fragment = await FragmentModel.find(fragmentId) + const fragmentHelper = new Fragment({ fragment }) + const { title: titleText } = await fragmentHelper.getFragmentData() + const { submittingAuthor } = await fragmentHelper.getAuthorData({ + UserModel, + }) + + const userHelper = new User({ UserModel }) + const eicName = await userHelper.getEiCName() + const { customId } = collection + + const { paragraph, ...bodyProps } = getEmailCopy({ + titleText, + emailType: 'author-he-removed', + }) + + const email = new Email({ + type: 'user', + fromEmail: `${eicName} <${staffEmail}>`, + toUser: { + email: submittingAuthor.email, + name: `${submittingAuthor.lastName}`, + }, + content: { + subject: `${customId}: Your manuscript's editor was changed`, + paragraph, + signatureName: eicName, + signatureJournal: journalName, + unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { + id: submittingAuthor.id, + token: submittingAuthor.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + }, + notifyInvitedHEWhenRemoved: async ({ + baseUrl, + invitedHE, + collection, + models: { User: UserModel, Fragment: FragmentModel }, + }) => { + const fragmentId = last(collection.fragments) + const fragment = await FragmentModel.find(fragmentId) + const fragmentHelper = new Fragment({ fragment }) + const { title: titleText } = await fragmentHelper.getFragmentData() + + const userHelper = new User({ UserModel }) + const eicName = await userHelper.getEiCName() + const { customId } = collection + + const { paragraph, ...bodyProps } = getEmailCopy({ + titleText, + emailType: 'he-he-removed', + }) + + const email = new Email({ + type: 'user', + fromEmail: `${eicName} <${staffEmail}>`, + toUser: { + email: invitedHE.email, + name: `${invitedHE.lastName}`, + }, + content: { + subject: `${customId}: The editor in chief removed you from ${titleText}`, + paragraph, + signatureName: eicName, + signatureJournal: journalName, + unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { + id: invitedHE.id, + token: invitedHE.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + }, sendEiCEmail: async ({ reason, baseUrl, diff --git a/packages/component-invite/src/tests/collectionsInvitations/delete.test.js b/packages/component-invite/src/tests/collectionsInvitations/delete.test.js index c6e6089b02c4946f6a624911036cf2736a905d77..184f8818018ca0bab8fb24db9a5e844cc1273ed3 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/delete.test.js +++ b/packages/component-invite/src/tests/collectionsInvitations/delete.test.js @@ -9,6 +9,9 @@ const { Model, fixtures } = fixturesService jest.mock('@pubsweet/component-send-email', () => ({ send: jest.fn(), })) +jest.mock('pubsweet-component-mts-package/src/PackageManager', () => ({ + deleteFilesS3: jest.fn(), +})) const path = '../routes/collectionsInvitations/delete' const route = { @@ -86,4 +89,19 @@ describe('Delete Collections Invitations route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Unauthorized.') }) + it('should return success when the EiC revokes a HE', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const res = await requests.sendRequest({ + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + invitationId: collection.invitations[1].id, + }, + }) + expect(res.statusCode).toBe(200) + }) }) diff --git a/packages/component-manuscript-manager/src/routes/fragments/patch.js b/packages/component-manuscript-manager/src/routes/fragments/patch.js index acd037254ddb9ada6417d99980a2a30588799d08..6a3e11ef06411ccd3868b8da6aca8389289f0d6a 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragments/patch.js @@ -24,7 +24,8 @@ module.exports = models => async (req, res) => { fragment = await models.Fragment.find(fragmentId) if (!fragment.revision) { return res.status(400).json({ - error: 'No revision has been found.', + error: + 'Your Handling Editor was changed. A new handling editor will be assigned to your manuscript soon. Sorry for the inconvenience.', }) } diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js index 7846722ed2f83db255a59ff80f95c142ccf2d3ec..7cd5b2ae49984d23fff26a53ae1bfb38a19f8244 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js @@ -1,3 +1,4 @@ +const { find } = require('lodash') const { services, authsome: authsomeHelper, @@ -8,6 +9,7 @@ const Notification = require('../../notifications/notification') module.exports = models => async (req, res) => { const { collectionId, fragmentId, recommendationId } = req.params + const userId = req.user let collection, fragment try { collection = await models.Collection.find(collectionId) @@ -25,7 +27,7 @@ module.exports = models => async (req, res) => { if (!recommendation) return res.status(404).json({ error: 'Recommendation not found.' }) - if (recommendation.userId !== req.user) + if (recommendation.userId !== userId) return res.status(403).json({ error: 'Unauthorized.', }) @@ -35,14 +37,14 @@ module.exports = models => async (req, res) => { fragment, path: req.route.path, } - const canPatch = await authsome.can(req.user, 'PATCH', target) + const canPatch = await authsome.can(userId, 'PATCH', target) if (!canPatch) return res.status(403).json({ error: 'Unauthorized.', }) const UserModel = models.User - const reviewer = await UserModel.find(req.user) + const reviewer = await UserModel.find(userId) Object.assign(recommendation, req.body) recommendation.updatedOn = Date.now() @@ -62,6 +64,16 @@ module.exports = models => async (req, res) => { const collectionHelper = new Collection({ collection }) collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) } + + const collectionHelper = new Collection({ collection }) + const reviewerNumber = await collectionHelper.getReviewerNumber({ + userId, + }) + + find(fragment.invitations, [ + 'userId', + userId, + ]).reviewerNumber = reviewerNumber } fragment.save() diff --git a/packages/component-manuscript-manager/src/tests/collections/get.test.js b/packages/component-manuscript-manager/src/tests/collections/get.test.js index aa4a1cfec4a3e9de92809e07e056a81b2aaede1f..b721805c99c9b00833fcb953cc16f5a529840164 100644 --- a/packages/component-manuscript-manager/src/tests/collections/get.test.js +++ b/packages/component-manuscript-manager/src/tests/collections/get.test.js @@ -58,7 +58,6 @@ describe('Get collections route handler', () => { expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) - expect(data).toHaveLength(2) expect(data[0].type).toEqual('collection') expect(data[0].currentVersion.recommendations).toHaveLength(6) diff --git a/packages/component-manuscript-manager/src/tests/fragments/patch.test.js b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js index f4e2147a5604bf4f77e0782fdd1f33cb28ec9f58..be867cbe7df7674d91c619d7837a94ed08e808e6 100644 --- a/packages/component-manuscript-manager/src/tests/fragments/patch.test.js +++ b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js @@ -148,7 +148,9 @@ describe('Patch fragments route handler', () => { expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('No revision has been found.') + expect(data.error).toEqual( + 'Your Handling Editor was changed. A new handling editor will be assigned to your manuscript soon. Sorry for the inconvenience.', + ) }) it('should return an error when the user is inactive', async () => { const { inactiveUser } = testFixtures.users diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index 4953fa278e884980a5bfe28168c62a6a0e97a455..5f870ecfa43fe24b4886780c6a78feaf2f0d675b 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -17,9 +17,9 @@ import { ResponseToRevisionRequest, } from 'pubsweet-component-faraday-ui' -import ReviewerReportCard from './ReviewReportCard' import ReviewerReportForm from './ReviewerReportForm' import EditorialCommentCard from './EditorialCommentCard' +import ReviewerReports from './ReviewerReports' const messagesLabel = { 'return-to-handling-editor': 'Comments for Handling Editor', @@ -122,17 +122,25 @@ const ManuscriptLayout = ({ <AuthorReviews currentUser={currentUser} getSignedUrl={getSignedUrl} + invitations={invitationsWithReviewers} journal={journal} - reports={reviewerReports} + reviewerReports={reviewerReports} token={get(currentUser, 'token')} /> )} - {submittedOwnRecommendation && ( - <ReviewerReportCard + {get( + currentUser, + 'permissions.reviewersCanViewReviewerReports', + false, + ) && ( + <ReviewerReports + currentUser={currentUser} getSignedUrl={getSignedUrl} + invitations={invitationsWithReviewers} + isLatestVersion={isLatestVersion} journal={journal} - report={submittedOwnRecommendation} + reviewerReports={reviewerRecommendations} token={get(currentUser, 'token')} /> )} @@ -163,17 +171,18 @@ const ManuscriptLayout = ({ /> )} - {get(currentUser, 'isInvitedHE', false) && ( - <ResponseToInvitation - commentsOn="decline" - expanded={heResponseExpanded} - formValues={formValues.responseToInvitation} - label="Do you agree to be the handling editor for this manuscript?" - onResponse={inviteHandlingEditor.onHEResponse} - title="Respond to Editorial Invitation" - toggle={toggleHEResponse} - /> - )} + {isLatestVersion && + get(currentUser, 'isInvitedHE', false) && ( + <ResponseToInvitation + commentsOn="decline" + expanded={heResponseExpanded} + formValues={formValues.responseToInvitation} + label="Do you agree to be the handling editor for this manuscript?" + onResponse={inviteHandlingEditor.onHEResponse} + title="Respond to Editorial Invitation" + toggle={toggleHEResponse} + /> + )} {get(currentUser, 'isInvitedToReview', false) && ( <ResponseToInvitation @@ -185,14 +194,16 @@ const ManuscriptLayout = ({ /> )} - <ManuscriptAssignHE - assignHE={inviteHandlingEditor.assignHE} - currentUser={currentUser} - expanded={heExpanded} - handlingEditors={handlingEditors} - isFetching={isFetchingData.editorsFetching} - toggle={toggleAssignHE} - /> + {isLatestVersion && ( + <ManuscriptAssignHE + assignHE={inviteHandlingEditor.assignHE} + currentUser={currentUser} + expanded={heExpanded} + handlingEditors={handlingEditors} + isFetching={isFetchingData.editorsFetching} + toggle={toggleAssignHE} + /> + )} {get(currentUser, 'permissions.canViewReviewersDetails', false) && ( <ReviewerDetails diff --git a/packages/component-manuscript/src/components/ManuscriptPage.js b/packages/component-manuscript/src/components/ManuscriptPage.js index 89b5dadefcc611d56684ad1b442b326f2f3870ab..9ecbb8297bdce18ebcfd1b00c0b26e2b624af545 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -57,6 +57,7 @@ import { getOwnPendingRecommendation, getOwnSubmittedRecommendation, canAuthorViewEditorialComments, + reviewersCanViewReviewerReports, canHEMakeRecommendationToPublish, getFragmentReviewerRecommendations, getInvitationsWithReviewersForFragment, @@ -177,7 +178,11 @@ export default compose( collection, statuses: get(journal, 'statuses', {}), }), - canAssignHE: canAssignHE(state, collection), + canAssignHE: canAssignHE( + state, + collection, + isLatestVersion(collection, fragment), + ), canViewReports: canViewReports(state, match.params.project), canViewEditorialComments: canViewEditorialComments( state, @@ -195,6 +200,11 @@ export default compose( collection, get(fragment, 'id', ''), ), + reviewersCanViewReviewerReports: reviewersCanViewReviewerReports( + state, + collection, + get(fragment, 'id', ''), + ), canOverrideTechChecks: canOverrideTechnicalChecks(state, collection), canAuthorViewEditorialComments: canAuthorViewEditorialComments( state, diff --git a/packages/component-manuscript/src/components/ReviewerReports.js b/packages/component-manuscript/src/components/ReviewerReports.js new file mode 100644 index 0000000000000000000000000000000000000000..9d838360390efb16be9bb8a837d299b866c55c8a --- /dev/null +++ b/packages/component-manuscript/src/components/ReviewerReports.js @@ -0,0 +1,88 @@ +import React from 'react' +import { compose, withProps } from 'recompose' +import { get } from 'lodash' +import { + ReviewerReport, + ContextualBox, + withFilePreview, + withFileDownload, + Text, + Row, + indexReviewers, +} from 'pubsweet-component-faraday-ui' + +const SubmittedReports = ({ reports }) => ( + <Row fitContent justify="flex-end"> + <Text customId mr={1 / 2}> + {reports} + </Text> + <Text mr={1 / 2} pr={1 / 2} secondary> + {' '} + submitted + </Text> + </Row> +) + +const ReviewerReports = ({ + journal, + reports, + previewFile, + downloadFile, + isLatestVersion, + currentUser, + token, + invitations, + reviwerReports, +}) => ( + <ContextualBox + label={isLatestVersion ? 'Your Report' : 'Reviewer Reports'} + mb={2} + rightChildren={<SubmittedReports reports={reports.length} />} + startExpanded + > + {reports.map(report => ( + <ReviewerReport + currentUser={currentUser} + journal={journal} + key={report.id} + onDownload={downloadFile} + onPreview={previewFile} + report={report} + reviewerNumber={report.reviewerNumber} + showOwner={report.userId === currentUser.id} + /> + ))} + </ContextualBox> +) + +export default compose( + withFileDownload, + withFilePreview, + withProps( + ({ + invitations = [], + publonReviewers = [], + reviewerReports = [], + currentUser, + isLatestVersion, + }) => ({ + token: get(currentUser, 'token', ''), + publonReviewers, + invitations: invitations.map(i => ({ + ...i, + review: reviewerReports.find(r => r.userId === i.userId), + })), + reports: isLatestVersion + ? indexReviewers( + reviewerReports.filter( + r => r.submittedOn && r.userId === currentUser.id, + ), + invitations, + ) + : indexReviewers( + reviewerReports.filter(r => r.submittedOn), + invitations, + ), + }), + ), +)(ReviewerReports) diff --git a/packages/component-manuscript/src/redux/editors.js b/packages/component-manuscript/src/redux/editors.js index e28c0dd29ff392776fa2092639ec8fceda19026a..2ff500a7c82c6d94db4b74faaa837b961b6c1b04 100644 --- a/packages/component-manuscript/src/redux/editors.js +++ b/packages/component-manuscript/src/redux/editors.js @@ -24,13 +24,14 @@ export const selectHandlingEditors = state => .value() const canAssignHEStatuses = ['submitted'] -export const canAssignHE = (state, collection = {}) => { +export const canAssignHE = (state, collection = {}, isLatestVersion) => { const isEIC = currentUserIs(state, 'adminEiC') const hasHE = get(collection, 'handlingEditor', false) return ( isEIC && !hasHE && + isLatestVersion && canAssignHEStatuses.includes(get(collection, 'status', 'draft')) ) } diff --git a/packages/component-manuscript/src/submitRevision/utils.js b/packages/component-manuscript/src/submitRevision/utils.js index bee60515f02ca64d53e90cb8478ebe32de58ea8b..970d5303370d5be965c0c4ad64bae08d7e1ec5d9 100644 --- a/packages/component-manuscript/src/submitRevision/utils.js +++ b/packages/component-manuscript/src/submitRevision/utils.js @@ -5,7 +5,7 @@ import { autosaveRequest } from 'pubsweet-component-wizard/src/redux/autosave' import { submitRevision } from 'pubsweet-component-wizard/src/redux/conversion' const parseRevision = (values, fragment) => ({ - ...fragment, + ...omit(fragment, 'recommendations'), revision: { ...values, }, diff --git a/packages/xpub-faraday/config/authsome-helpers.js b/packages/xpub-faraday/config/authsome-helpers.js index 08e583c721fa6103de2f4729e0f2e64a348567a7..39f753c539a754d22829a00a0397277236eb40b1 100644 --- a/packages/xpub-faraday/config/authsome-helpers.js +++ b/packages/xpub-faraday/config/authsome-helpers.js @@ -4,8 +4,6 @@ const { omit, get, last, chain } = require('lodash') const statuses = config.get('statuses') -const keysToOmit = [`email`, `id`] -const authorCannotViewHENameStatuses = ['heInvited'] const authorAllowedStatuses = ['revisionRequested', 'rejected', 'accepted'] const getTeamsByPermissions = async ( @@ -72,23 +70,57 @@ const filterAuthorRecommendations = (recommendations, status, isLast) => { return [] } +const filterRecommendationsFromLastVersion = (recommendations, user) => + recommendations + .filter( + r => + r.userId === user.id || r.recommendationType === 'editorRecommendation', + ) + .map(r => ({ + ...r, + comments: r.comments.filter(c => c.public === true), + })) + +const filterRecommendationsFromOlderVersions = (recommendations, user) => { + const ownRecommendation = recommendations.find(r => r.userId === user.id) + if (ownRecommendation) { + return recommendations + .filter( + r => r.submittedOn || r.recommendationType === 'editorRecommendation', + ) + .map( + r => + r.userId !== ownRecommendation.userId + ? { ...r, comments: r.comments.filter(c => c.public === true) } + : { ...r }, + ) + } + return [] +} + const stripeCollectionByRole = ({ collection = {}, role = '' }) => { if (role === 'author') { - const { handlingEditor } = collection - - if (authorCannotViewHENameStatuses.includes(collection.status)) { + if (collection.status === 'heInvited') { return { ...collection, - handlingEditor: handlingEditor && - !handlingEditor.isAccepted && { - ...omit(handlingEditor, keysToOmit), - name: 'Assigned', - }, + handlingEditor: { + name: 'Assigned', + }, + } + } + if (collection.status === 'submitted') { + return { + ...collection, + handlingEditor: { + name: 'Unassigned', + }, } } } + return collection } + const stripeFragmentByRole = ({ fragment = {}, role = '', @@ -96,7 +128,8 @@ const stripeFragmentByRole = ({ user = {}, isLast = false, }) => { - const { recommendations, files, authors } = fragment + const { files, authors, recommendations = [] } = fragment + let recommendationsFromFragment = [] switch (role) { case 'author': return { @@ -106,22 +139,22 @@ const stripeFragmentByRole = ({ : [], } case 'reviewer': + if (isLast) { + recommendationsFromFragment = filterRecommendationsFromLastVersion( + recommendations, + user, + ) + } else { + recommendationsFromFragment = filterRecommendationsFromOlderVersions( + recommendations, + user, + ) + } return { ...fragment, files: omit(files, ['coverLetter']), authors: authors.map(a => omit(a, ['email'])), - recommendations: recommendations - ? recommendations - .filter( - r => - r.userId === user.id || - r.recommendationType === 'editorRecommendation', - ) - .map(r => ({ - ...r, - comments: r.comments.filter(c => c.public === true), - })) - : [], + recommendations: recommendationsFromFragment, } case 'handlingEditor': return { @@ -175,10 +208,13 @@ const getCollections = async ({ user, models }) => { } else { fragment = await models.Fragment.find(userPermission.objectId) collection = await models.Collection.find(fragment.collectionId) + const latestFragmentId = + collection.fragments[collection.fragments.length - 1] collection.currentVersion = stripeFragmentByRole({ fragment, role: userPermission.role, user, + isLast: latestFragmentId === fragment.id, }) } } catch (e) { diff --git a/packages/xpub-faraday/config/authsome-mode.js b/packages/xpub-faraday/config/authsome-mode.js index fe678f981257b9e3a67647f062f3f15ef0f87f31..c9df55d68c485557d2c6031a4a5a35e6f9f752a9 100644 --- a/packages/xpub-faraday/config/authsome-mode.js +++ b/packages/xpub-faraday/config/authsome-mode.js @@ -93,8 +93,13 @@ async function applyAuthenticatedUserPolicy(user, operation, object, context) { FragmentModel: context.models.Fragment, }) + const parsedCollection = helpers.stripeCollectionByRole({ + collection, + role, + }) + return { - ...collection, + ...parsedCollection, ...parsedStatuses, fragments: role !== 'reviewer' diff --git a/packages/xpub-faraday/tests/config/authsome-helpers.test.js b/packages/xpub-faraday/tests/config/authsome-helpers.test.js index c5634168fdf6ab29134874c7c3fa9ee96183b2d0..41fa760afb11a7bb0cb7896549aa19151e3ce79a 100644 --- a/packages/xpub-faraday/tests/config/authsome-helpers.test.js +++ b/packages/xpub-faraday/tests/config/authsome-helpers.test.js @@ -19,7 +19,7 @@ describe('Authsome Helpers', () => { expect(newCollection).toBeTruthy() }) - it('Author should not see HE name on dashboard before HE accepts invitation', () => { + it('Author should see Assigned instead of HE name on dashboard before HE accepts invitation', () => { const { collection } = testFixtures.collections collection.handlingEditor = { ...collection.handlingEditor, @@ -86,6 +86,19 @@ describe('Authsome Helpers', () => { const { handlingEditor = {} } = newCollection expect(handlingEditor.name).not.toEqual('Assigned') }) + + it('Author should see Unassigned insted of HE name before HE is invited', () => { + const { collection } = testFixtures.collections + collection.status = 'submitted' + const role = 'author' + const newCollection = ah.stripeCollectionByRole({ + collection, + role, + }) + const { handlingEditor = {} } = newCollection + expect(handlingEditor.name).toEqual('Unassigned') + }) + it('stripeCollection - returns if collection does not have HE', () => { const { collection } = testFixtures.collections delete collection.handlingEditor @@ -116,7 +129,7 @@ describe('Authsome Helpers', () => { const { files = {} } = result expect(files.coverLetter).toBeFalsy() }) - it('reviewer should not see private comments', () => { + it('reviewer should not see private comments on the last version of the manuscript', () => { const { fragment } = testFixtures.fragments fragment.recommendations = [ { @@ -132,13 +145,59 @@ describe('Authsome Helpers', () => { ], }, ] - const result = ah.stripeFragmentByRole({ fragment, role: 'reviewer' }) + const result = ah.stripeFragmentByRole({ + fragment, + role: 'reviewer', + isLast: true, + }) const { recommendations } = result expect(recommendations).toHaveLength(1) expect(recommendations[0].comments).toHaveLength(1) expect(recommendations[0].comments[0].public).toEqual(true) }) + it('reviewer should see other reviewers recommendations on previous version if he submitted a review on that fragment', () => { + const { fragment } = testFixtures.fragments + const { answerReviewer } = testFixtures.users + + const result = ah.stripeFragmentByRole({ + fragment, + role: 'reviewer', + isLast: false, + user: answerReviewer, + }) + const { recommendations } = result + expect(recommendations).toHaveLength(7) + }) + + it('reviewer should not see other reviewers recommendations on latest fragment', () => { + const { fragment } = testFixtures.fragments + const { answerReviewer } = testFixtures.users + + const result = ah.stripeFragmentByRole({ + fragment, + role: 'reviewer', + isLast: true, + user: answerReviewer, + }) + const { recommendations } = result + expect(recommendations).toHaveLength(6) + }) + + it('reviewer should not see any reviewer recommendation on previous version if he did not submit a review on that fragment', () => { + const { fragment } = testFixtures.fragments + const { inactiveReviewer } = testFixtures.users + + const result = ah.stripeFragmentByRole({ + fragment, + role: 'reviewer', + isLast: false, + user: inactiveReviewer, + }) + const { recommendations } = result + expect(recommendations).toHaveLength(0) + }) + it('author should not see recommendations if a decision has not been made', () => { const { fragment } = testFixtures.fragments fragment.recommendations = [