diff --git a/Automation/src/test/java/Utils.java b/Automation/src/test/java/Utils.java index 149e3ef2a537ce9178435b50ab619a8ee59c5095..74b8cb31429e4f0b97371b0b00b5f9b8b1c78064 100644 --- a/Automation/src/test/java/Utils.java +++ b/Automation/src/test/java/Utils.java @@ -81,7 +81,7 @@ public class Utils { driver.findElement(By.name("email")).sendKeys(email); driver.findElement(By.name("password")).sendKeys(password); - driver.findElement(By.name("confirmPassword")).sendKeys(password); + driver.findElement(By.name("confirmNewPassword")).sendKeys(password); driver.findElement(By.xpath(".//button[contains(text(),'"+"CONFIRM"+"')]")).click(); 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..3c48b4f1aaf15ce372a53952f776dd1049bb49bb 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 ownRecommendation = getOwnRecommendations(state, fragmentId) + return ( + isReviewer && + authorAndReviewersCanViewReportsDetailsStatuses.includes( + get(collection, 'status', 'draft'), + ) && + get(ownRecommendation[0], 'submittedOn', false) ) } @@ -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 ) } @@ -217,17 +270,9 @@ export const getHERecommendation = (state, collectionId, fragmentId) => { ) } -const canMakeDecisionStatuses = [ - 'submitted', - 'pendingApproval', - 'underReview', - 'reviewCompleted', -] export const canMakeDecision = (state, collection = {}) => { - const status = get(collection, 'status', 'draft') - const isEIC = currentUserIs(state, 'adminEiC') - return isEIC && canMakeDecisionStatuses.includes(status) + return isEIC } const collectionReviewerReports = state => @@ -326,6 +371,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 +548,15 @@ 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' && + !get(ownRecommendation[0], 'submittedOn', false) + ) } + +export const isFetchingFromAutosave = state => + get(state.autosave, 'isFetching', false) diff --git a/packages/component-faraday-ui/src/AuthorCard.js b/packages/component-faraday-ui/src/AuthorCard.js index 2ace7fa985268bb535cdeff598a1a5ac7a030433..36f74f2d439a408fe6024014e11d409564fec1d4 100644 --- a/packages/component-faraday-ui/src/AuthorCard.js +++ b/packages/component-faraday-ui/src/AuthorCard.js @@ -4,14 +4,7 @@ import styled from 'styled-components' import { th } from '@pubsweet/ui-toolkit' import { required } from 'xpub-validators' import { reduxForm, Field } from 'redux-form' -import { - Menu, - H3, - ValidatedField, - TextField, - Checkbox, - Spinner, -} from '@pubsweet/ui' +import { H3, ValidatedField, TextField, Checkbox, Spinner } from '@pubsweet/ui' import { compose, withState, @@ -20,7 +13,7 @@ import { setDisplayName, } from 'recompose' -import { withCountries } from 'pubsweet-component-faraday-ui' +import { MenuCountry } from 'pubsweet-component-faraday-ui' import { Tag, Label, Row, Item, PersonInfo, IconButton, OpenModal } from './' import { validators } from './helpers' @@ -129,7 +122,6 @@ const AuthorTitle = ({ // #region AuthorEdit const AuthorEdit = ({ - countries, author, editMode, listIndex, @@ -196,7 +188,7 @@ const AuthorEdit = ({ <Label required>Country</Label> <ValidatedField component={input => ( - <Menu {...input} options={countries} placeholder="Please select" /> + <MenuCountry {...input} placeholder="Please select" /> )} data-test-id="author-card-country" name="country" @@ -208,7 +200,6 @@ const AuthorEdit = ({ // #endregion const EnhancedAuthorEdit = compose( - withCountries, withProps(({ author }) => ({ initialValues: author, })), diff --git a/packages/component-faraday-ui/src/AuthorReply.js b/packages/component-faraday-ui/src/AuthorReply.js index 4bbb8d22b3218279bbedaedb1af03b7bd7f1bca0..872a4be66501eac3ca9dcf4aadcb4c18d94178ad 100644 --- a/packages/component-faraday-ui/src/AuthorReply.js +++ b/packages/component-faraday-ui/src/AuthorReply.js @@ -26,7 +26,7 @@ const AuthorReply = ({ <Row mb={1}> <Item vertical> <Label mb={1 / 2}>Author Reply</Label> - <Text>{replyContent}</Text> + <Text whiteSpace="pre-wrap">{replyContent}</Text> </Item> </Row> <Text ml={1} mr={1} whiteSpace="nowrap"> diff --git a/packages/component-faraday-ui/src/EditorialReportCard.js b/packages/component-faraday-ui/src/EditorialReportCard.js index 1f394ee94dd7a7af80267eadfc9e59aeb07161b1..f43ddb1072b43579b889eaffcbe8b34226258e3d 100644 --- a/packages/component-faraday-ui/src/EditorialReportCard.js +++ b/packages/component-faraday-ui/src/EditorialReportCard.js @@ -51,7 +51,7 @@ const EditorialReportCard = ({ <Row mb={2}> <Item vertical> <Label mb={1 / 2}>{publicLabel}</Label> - <Text>{publicReport}</Text> + <Text whiteSpace="pre-wrap">{publicReport}</Text> </Item> </Row> )} @@ -60,7 +60,7 @@ const EditorialReportCard = ({ <Row mb={2}> <Item vertical> <Label mb={1 / 2}>{privateLabel}</Label> - <Text>{privateReport}</Text> + <Text whiteSpace="pre-wrap">{privateReport}</Text> </Item> </Row> )} diff --git a/packages/component-faraday-ui/src/IconButton.js b/packages/component-faraday-ui/src/IconButton.js index 5e4b4acfc0f531676ed49913b60bd8b960e871ee..ac1ef02ba87f4616ca9219694b41852b9bd1e5b5 100644 --- a/packages/component-faraday-ui/src/IconButton.js +++ b/packages/component-faraday-ui/src/IconButton.js @@ -1,7 +1,6 @@ import React from 'react' import { Icon } from '@pubsweet/ui' import styled from 'styled-components' - import { positionHelper, marginHelper, paddingHelper } from './styledHelpers' const IconButton = styled.div` @@ -10,7 +9,7 @@ const IconButton = styled.div` display: flex; justify-content: center; opacity: ${props => (props.disabled ? 0.7 : 1)}; - + pointer-events: ${props => (props.unclickable ? 'none' : 'auto')}; &:hover { opacity: 0.7; } @@ -18,16 +17,23 @@ const IconButton = styled.div` &[disabled] { cursor: not-allowed; } - ${marginHelper}; ${paddingHelper}; ${positionHelper}; ` -export default ({ icon, onClick, iconSize = 3, disabled, ...props }) => ( +export default ({ + icon, + onClick, + unclickable, + iconSize = 3, + disabled, + ...props +}) => ( <IconButton disabled={disabled} onClick={!disabled ? onClick : null} + unclickable={unclickable} {...props} > <Icon size={iconSize} {...props}> diff --git a/packages/component-faraday-ui/src/InviteReviewers.js b/packages/component-faraday-ui/src/InviteReviewers.js index 7c93ebb316f9a1d7a10c664548a6ee94c6864cdd..7f07b70cba1b4e7944aea14142d5bcd1632a1fad 100644 --- a/packages/component-faraday-ui/src/InviteReviewers.js +++ b/packages/component-faraday-ui/src/InviteReviewers.js @@ -5,7 +5,9 @@ import { reduxForm } from 'redux-form' import { th } from '@pubsweet/ui-toolkit' import { required } from 'xpub-validators' import { withModal } from 'pubsweet-component-modal/src/components' -import { Button, H4, Menu, TextField, ValidatedField } from '@pubsweet/ui' +import { Button, H4, TextField, ValidatedField } from '@pubsweet/ui' + +import { MenuCountry } from 'pubsweet-component-faraday-ui' import { Row, @@ -15,10 +17,9 @@ import { ItemOverrideAlert, withFetching, validators, - withCountries, } from '../' -const InviteReviewers = ({ countries, handleSubmit, reset }) => ( +const InviteReviewers = ({ handleSubmit, reset }) => ( <Root> <Row justify="space-between" mb={2}> <H4>Invite reviewer</H4> @@ -82,7 +83,9 @@ const InviteReviewers = ({ countries, handleSubmit, reset }) => ( <ItemOverrideAlert vertical> <Label required>Country</Label> <ValidatedField - component={input => <Menu options={countries} {...input} />} + component={input => ( + <MenuCountry {...input} placeholder="Please select" /> + )} name="country" validate={[required]} /> @@ -93,7 +96,6 @@ const InviteReviewers = ({ countries, handleSubmit, reset }) => ( export default compose( withFetching, - withCountries, withModal(({ isFetching, modalKey }) => ({ modalKey, isFetching, 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/MenuCountry.js b/packages/component-faraday-ui/src/MenuCountry.js new file mode 100644 index 0000000000000000000000000000000000000000..8ca9f618bb2612ebdf04dea4a5c0e781ba2a39ad --- /dev/null +++ b/packages/component-faraday-ui/src/MenuCountry.js @@ -0,0 +1,81 @@ +import React from 'react' +import { Menu } from '@pubsweet/ui' +import { startsWith, toLower, get } from 'lodash' +import { compose, withState, withHandlers } from 'recompose' +import styled from 'styled-components' +import { th } from '@pubsweet/ui-toolkit' + +import { withCountries } from 'pubsweet-component-faraday-ui' + +const filteredCountries = (countries, userInput) => + countries.filter(o => startsWith(toLower(o.label), toLower(userInput))) + +const firstFilteredCountry = props => + filteredCountries(props.countries, props.userInput)[0] + +const CustomOpener = ({ + selected, + userInput, + toggleMenu, + placeholder, + optionLabel, + onChange, + onEnter, +}) => ( + <Input + onChange={onChange} + onClick={toggleMenu} + onKeyUp={onEnter} + placeholder={selected ? optionLabel(selected) : placeholder} + value={userInput} + /> +) + +const MenuCountry = ({ countries = [], ...input }) => ( + <Menu + {...input} + options={filteredCountries(countries, input.userInput)} + placeholder="Please select" + renderOpener={CustomOpener} + /> +) + +const enhance = compose( + withCountries, + withState('userInput', 'updateUserInput', ''), + withHandlers({ + onChange: ({ updateUserInput, onChange }) => value => { + // this value is an input DOM event while typing and a dropdown value when + // selected + if (typeof value === 'string') { + onChange(value) + } + updateUserInput(get(value, 'target.value', '')) + }, + onEnter: props => event => { + if (event.which === 13) { + props.onChange(firstFilteredCountry(props).value) + props.updateUserInput(firstFilteredCountry(props).label) + } + }, + }), +) + +export default enhance(MenuCountry) + +const Input = styled.input` + width: 100%; + height: calc(${th('gridUnit')} * 4); + border: ${th('accordion.border')}; + border-radius: ${th('borderRadius')}; + padding: 0 ${th('gridUnit')}; + ::placeholder { + color: ${th('colorText')}; + opacity: 1; + font-family: ${th('fontWriting')}; + } + :focus { + border-color: ${th('action.colorActive')} + outline: none; + } +` diff --git a/packages/component-faraday-ui/src/PasswordValidation.js b/packages/component-faraday-ui/src/PasswordValidation.js new file mode 100644 index 0000000000000000000000000000000000000000..6dacbd6349dbae4446a0c68407b4d4d486dd6deb --- /dev/null +++ b/packages/component-faraday-ui/src/PasswordValidation.js @@ -0,0 +1,182 @@ +import React, { Fragment } from 'react' +import PropTypes from 'prop-types' +import { required } from 'xpub-validators' +import { connect } from 'react-redux' +import { getFormValues } from 'redux-form' +import { ValidatedField, TextField } from '@pubsweet/ui' +import { th } from '@pubsweet/ui-toolkit' +import { Row, Item, Label, IconButton } from 'pubsweet-component-faraday-ui' +import styled, { css } from 'styled-components' +import { get } from 'lodash' +import { withProps, compose } from 'recompose' +import { + minLength, + atLeastOneDigit, + atLeastOneUppercase, + atLeastOneLowerrcase, + atLeastOnePunctuation, +} from './Utils.js' + +const PasswordField = input => <TextField {...input} type="password" /> +const PasswordValidation = ({ + formName, + formLabel, + minLength, + atLeastOneDigit, + atLeastOneUppercase, + submitFailed, + atLeastOneLowerrcase, + atLeastOnePunctuation, +}) => ( + <Fragment> + <Row mb={2}> + <Item data-test-id="sign-up-password" vertical> + <Label required>{formLabel}</Label> + <ValidatedField + component={PasswordField} + name="password" + validate={[required]} + /> + </Item> + </Row> + + <Row mb={1}> + <Item data-test-id="sign-up-confirm-password" vertical> + <Label required>Retype Password</Label> + <ValidatedField + component={PasswordField} + name="confirmNewPassword" + validate={[required]} + /> + </Item> + </Row> + + <Row alignItems="center" justify="flex-start" mb={-1 / 2}> + <IconButton icon="info" iconSize={2} ml={-1 / 2} primary unclickable /> + <RulesTitle>The password must contain: </RulesTitle> + </Row> + + <Rules> + <Row justify="flex-start"> + <Rule error={submitFailed && !minLength} fulfilled={minLength}> + at least 6 characters + </Rule> + </Row> + + <Row justify="flex-start"> + <Rule + error={submitFailed && !atLeastOneUppercase} + fulfilled={atLeastOneUppercase} + > + at least 1 uppercase character (A-Z) + </Rule> + </Row> + + <Row justify="flex-start"> + <Rule + error={submitFailed && !atLeastOneLowerrcase} + fulfilled={atLeastOneLowerrcase} + > + at least 1 lowercase character (a-z) + </Rule> + </Row> + + <Row justify="flex-start"> + <Rule + error={submitFailed && !atLeastOneDigit} + fulfilled={atLeastOneDigit} + > + at least 1 digit (0-9) + </Rule> + </Row> + + <Row justify="flex-start" mb={3}> + <Rule + error={submitFailed && !atLeastOnePunctuation} + fulfilled={atLeastOnePunctuation} + > + at least 1 special character (punctuation) + </Rule> + </Row> + </Rules> + </Fragment> +) +PasswordValidation.propTypes = { + /** Name of the redux form. */ + formName: PropTypes.string, + /** Label name for password input */ + formLabel: PropTypes.string, + /** If the password requirements are not fulfilled it will return true. */ + submitFailed: PropTypes.bool, + /** If the password's length is greater or equal than the minimum value, it will return true. */ + minLength: PropTypes.bool, + /** If the password has at least one digit it will return true. */ + atLeastOneDigit: PropTypes.bool, + /** If the password has at least one uppercase it will return true. */ + atLeastOneUppercase: PropTypes.bool, + /** If the password has at least one lowercase it will return true. */ + atLeastOneLowerrcase: PropTypes.bool, + /** If the password has at least one punctuation it will return true. */ + atLeastOnePunctuation: PropTypes.bool, +} +PasswordValidation.defaultProps = { + formName: undefined, + formLabel: undefined, + submitFailed: false, + minLength: undefined, + atLeastOneDigit: undefined, + atLeastOneUppercase: undefined, + atLeastOneLowerrcase: undefined, + atLeastOnePunctuation: undefined, +} + +export default compose( + connect((state, props) => ({ + formValues: getFormValues(props.formName)(state), + submitFailed: get(state, `form.${props.formName}.submitFailed`, false), + })), + withProps(({ formValues }) => ({ + minLength: minLength(get(formValues, 'password', ''), 6), + atLeastOneUppercase: atLeastOneUppercase(get(formValues, 'password', '')), + atLeastOneLowerrcase: atLeastOneLowerrcase(get(formValues, 'password', '')), + atLeastOneDigit: atLeastOneDigit(get(formValues, 'password', '')), + atLeastOnePunctuation: atLeastOnePunctuation( + get(formValues, 'password', ''), + ), + })), +)(PasswordValidation) + +const RulesTitle = styled.p` + margin: 0px; + background-color: ${th('colorBackgroundHue')}; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseMedium')}; + line-height: ${th('lineHeightHeading3')}; + color: ${th('colorSecondary')}; +` +const Rules = styled.div` + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseMedium')}; + line-height: ${th('lineHeightHeading4')}; + color: ${th('colorText')}; +` +const ruleHelper = props => { + const textDecoration = props.fulfilled ? 'line-through' : 'none' + + let textColor = 'inherit' + if (props.error) { + textColor = '#FC6A4B' + } else if (props.fulfilled) { + textColor = '#63A945' + } + + return css` + text-decoration: ${textDecoration}; + color: ${textColor}; + ` +} + +const Rule = styled.p` + margin: 0px; + ${ruleHelper}; +` diff --git a/packages/component-faraday-ui/src/PasswordValidation.md b/packages/component-faraday-ui/src/PasswordValidation.md new file mode 100644 index 0000000000000000000000000000000000000000..8a09ec872a3a0e02aa10eb86bd20ae1263274b7e --- /dev/null +++ b/packages/component-faraday-ui/src/PasswordValidation.md @@ -0,0 +1,16 @@ +```js +const React = require('react') +const reduxForm = require('redux-form').reduxForm; + +const PasswordField = () => ( + <PasswordValidation formLabel="Password" formName="signUpInvitation" /> +) + +const Form = reduxForm({ + form: 'signUpInvitation', + destroyOnUnmount: false, + forceUnregisterOnUnmount: true, +})(PasswordField); + +<Form /> +``` 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..36c67144b3e092102fe16e27a26c2228c0809402 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> @@ -45,7 +44,7 @@ const ReviewerReport = ({ <Row mb={2}> <Item vertical> <Label mb={1 / 2}>Report</Label> - <Text>{publicReport}</Text> + <Text whiteSpace="pre-wrap">{publicReport}</Text> </Item> </Row> )} @@ -69,27 +68,29 @@ const ReviewerReport = ({ <Row mb={2}> <Item vertical> <Label mb={1 / 2}>Confidential note for the Editorial Team</Label> - <Text>{privateReport}</Text> + <Text whiteSpace="pre-wrap">{privateReport}</Text> </Item> </Row> )} </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..b921052a43f28c03404f823a5244fd301cbb19dc 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 }, @@ -33,12 +33,12 @@ const ReviewerReportAuthor = ({ <Row mb={1}> <Item vertical> <Label mb={1 / 2}>Report</Label> - <Text>{publicReport}</Text> + <Text whiteSpace="pre-wrap">{publicReport}</Text> </Item> </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/ShadowedBox.js b/packages/component-faraday-ui/src/ShadowedBox.js index 62de72cb0638cd9e3f95d112c6559ddf6709b792..f30f7ef8a6e2d26597b9ce402ec378c83672d54d 100644 --- a/packages/component-faraday-ui/src/ShadowedBox.js +++ b/packages/component-faraday-ui/src/ShadowedBox.js @@ -3,24 +3,29 @@ import { H2 } from '@pubsweet/ui' import styled, { css } from 'styled-components' import { th } from '@pubsweet/ui-toolkit' -import { marginHelper } from './' +import { marginHelper, paddingHelper } from './' const width = props => css` width: calc(${th('gridUnit')} * ${get(props, 'width', 50)}); ` -export default styled.div` +export default styled.div.attrs({ + pt: props => get(props, 'pt', 2), + pr: props => get(props, 'pr', 2), + pb: props => get(props, 'pb', 2), + pl: props => get(props, 'pl', 2), +})` background-color: ${th('colorBackgroundHue')}; border-radius: ${th('borderRadius')}; box-shadow: ${th('boxShadow')}; display: flex; flex-direction: column; - padding: calc(${th('gridUnit')} * 2); position: ${props => get(props, 'position', 'initial')}; ${width}; ${marginHelper}; + ${paddingHelper}; ${H2} { text-align: center; diff --git a/packages/component-faraday-ui/src/UserProfile.js b/packages/component-faraday-ui/src/UserProfile.js index 292c04822b7ee0bdbcfb2662a12a91bb961cc8ca..e1bdc2074f60d3a1810ab5e1f595b1289b564c00 100644 --- a/packages/component-faraday-ui/src/UserProfile.js +++ b/packages/component-faraday-ui/src/UserProfile.js @@ -7,7 +7,7 @@ import { th } from '@pubsweet/ui-toolkit' import { required as requiredValidator } from 'xpub-validators' import { compose, withStateHandlers, withProps } from 'recompose' import { H3, Spinner, ValidatedField, TextField, Menu } from '@pubsweet/ui' -import { withCountries } from 'pubsweet-component-faraday-ui' +import { withCountries, MenuCountry } from 'pubsweet-component-faraday-ui' import { Row, @@ -179,7 +179,9 @@ const EditUserProfile = compose( <Item ml={1} vertical> <Label required>Country</Label> <ValidatedField - component={input => <Menu {...input} options={countries} />} + component={input => ( + <MenuCountry {...input} placeholder="Please select" /> + )} name="country" validate={[requiredValidator]} /> diff --git a/packages/component-faraday-ui/src/Utils.js b/packages/component-faraday-ui/src/Utils.js new file mode 100644 index 0000000000000000000000000000000000000000..95db294769acf7d5ee57e8e3be6c6321aaa388a6 --- /dev/null +++ b/packages/component-faraday-ui/src/Utils.js @@ -0,0 +1,75 @@ +export const minLength = (value, min) => !!(value && value.length >= min) + +export const atLeastOneUppercase = value => { + const uppercaseRegex = new RegExp(/([A-Z])+/) + return uppercaseRegex.test(value) +} +export const atLeastOneLowerrcase = value => { + const lowercaseRegex = new RegExp(/([a-z])+/) + return lowercaseRegex.test(value) +} +export const atLeastOneDigit = value => { + const digitRegex = new RegExp(/([0-9])+/) + return digitRegex.test(value) +} +export const atLeastOnePunctuation = value => { + const punctuationRegex = new RegExp(/([,'!@#$%^&*=(){}[\]<>?/\\|.:;_-])+/) + return punctuationRegex.test(value) +} + +export const passwordValidator = values => { + const errors = {} + const { password, confirmNewPassword } = values + if ( + !( + minLength(password, 6) && + atLeastOneUppercase(password) && + atLeastOneLowerrcase(password) && + atLeastOnePunctuation(password) && + atLeastOneDigit(password) + ) + ) { + errors.password = 'Password criteria not met' + } + if (!password) { + errors.password = 'Required' + } + if (!confirmNewPassword) { + errors.confirmNewPassword = 'Required' + } else if (confirmNewPassword !== password) { + errors.confirmNewPassword = "Passwords don't match." + } + + return errors +} + +export const changePasswordValidator = values => { + const { + currentPassword = '', + confirmNewPassword = '', + password = '', + } = values + const errors = {} + if ( + !( + minLength(password, 6) && + atLeastOneUppercase(password) && + atLeastOneLowerrcase(password) && + atLeastOnePunctuation(password) && + atLeastOneDigit(password) + ) + ) { + errors.password = 'Password criteria not met' + } + if (!currentPassword) { + errors.currentPassword = 'Required' + } + + if (!password) { + errors.password = 'Required' + } else if (password !== confirmNewPassword) { + errors.confirmNewPassword = "Passwords don't match." + } + + return errors +} 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/contextualBoxes/ReviewerReportForm.js b/packages/component-faraday-ui/src/contextualBoxes/ReviewerReportForm.js index acd472f65bebb023a945a626abc54357b33555d7..8d66da2f9edae85b6e2cb7236b5883f23136d0ce 100644 --- a/packages/component-faraday-ui/src/contextualBoxes/ReviewerReportForm.js +++ b/packages/component-faraday-ui/src/contextualBoxes/ReviewerReportForm.js @@ -3,6 +3,7 @@ import styled from 'styled-components' import { th } from '@pubsweet/ui-toolkit' import { required } from 'xpub-validators' import { Button, FilePicker, Menu, Spinner, ValidatedField } from '@pubsweet/ui' +import { initial } from 'lodash' import { Row, @@ -34,6 +35,7 @@ const ReviewerReportForm = ({ review = {}, formValues = {}, journal: { recommendations }, + isFetchingFromAutosave, }) => ( <ContextualBox expanded={expanded} @@ -51,7 +53,9 @@ const ReviewerReportForm = ({ > <Label required>Recommendation</Label> <ValidatedField - component={input => <Menu {...input} options={recommendations} />} + component={input => ( + <Menu {...input} options={initial(recommendations)} /> + )} name="recommendation" validate={[required]} /> @@ -145,7 +149,7 @@ const ReviewerReportForm = ({ </Row> )} <Row justify="flex-end" mt={1}> - {isFetching ? ( + {isFetching || isFetchingFromAutosave ? ( <Spinner /> ) : ( <Button diff --git a/packages/component-faraday-ui/src/helpers/formValidators.js b/packages/component-faraday-ui/src/helpers/formValidators.js index 3007139196800247ef5057ea5da9e8c9eeb27e4d..b3a6f6a56d39df8c0ed49732ebf311291ea2cbe9 100644 --- a/packages/component-faraday-ui/src/helpers/formValidators.js +++ b/packages/component-faraday-ui/src/helpers/formValidators.js @@ -10,10 +10,10 @@ export const passwordValidator = values => { if (!values.password) { errors.password = 'Required' } - if (!values.confirmPassword) { - errors.confirmPassword = 'Required' - } else if (values.confirmPassword !== values.password) { - errors.confirmPassword = "Passwords don't match." + if (!values.confirmNewPassword) { + errors.confirmNewPassword = 'Required' + } else if (values.confirmNewPassword !== values.password) { + errors.confirmNewPassword = "Passwords don't match." } return errors 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/index.js b/packages/component-faraday-ui/src/index.js index a99b60e4c1b2e18f15810beb84255aecd4ad4260..2d3859c78c7a2eda863ea5cef314771995ffa6b6 100644 --- a/packages/component-faraday-ui/src/index.js +++ b/packages/component-faraday-ui/src/index.js @@ -1,5 +1,6 @@ export * from './styledHelpers' export * from './helpers' +export * from './Utils' // modals export * from './modals' export * from './gridItems' @@ -48,6 +49,8 @@ export { default as WizardFiles } from './WizardFiles' export { default as TextTooltip } from './TextTooltip' export { default as EditorialReportCard } from './EditorialReportCard' export { default as ReviewerReportAuthor } from './ReviewerReportAuthor' +export { default as PasswordValidation } from './PasswordValidation' +export { default as MenuCountry } from './MenuCountry' export { SubmitRevision } from './submissionRevision' diff --git a/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptEicDecision.js b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptEicDecision.js index dc5314f613c215bb949c5735f25c395f83e8b5f6..07fcac0087453c6943265160174beb18b23f95f0 100644 --- a/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptEicDecision.js +++ b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptEicDecision.js @@ -1,5 +1,5 @@ import React from 'react' -import { get, last } from 'lodash' +import { get, initial } from 'lodash' import { compose, withProps } from 'recompose' import styled from 'styled-components' import { reduxForm } from 'redux-form' @@ -39,11 +39,24 @@ const eicDecisions = [ modalTitle: 'Reject Manuscript', modalSubtitle: 'A rejection decision is final', }, + { + value: 'revision', + label: 'Request Revision', + modalTitle: 'Request Revision', + }, ] +const filterOptions = (eicDecisions, status) => { + if (status === 'submitted') return eicDecisions.slice(2) + if (status === 'pendingApproval') return initial(eicDecisions) + return eicDecisions.slice(2, 3) +} const ManuscriptEicDecision = ({ - isFetching, + status, + options, + decision, formValues, + isFetching, handleSubmit, messagesLabel, collection = {}, @@ -55,42 +68,33 @@ const ManuscriptEicDecision = ({ {...rest} > <Root> - <Row justify="flex-start"> + <Row justify="flex-start" pl={1} pt={1}> <ItemOverrideAlert flex={0} vertical> <Label required>Decision</Label> <ValidatedField - component={input => ( - <Menu - {...input} - options={ - get(collection, 'status', 'submitted') !== 'pendingApproval' - ? [last(eicDecisions)] - : eicDecisions - } - /> - )} + component={input => <Menu {...input} options={options} />} name="decision" validate={[required]} /> </ItemOverrideAlert> </Row> - {get(formValues, 'decision') !== 'publish' && ( - <Row mt={2}> + {decision !== 'publish' && ( + <Row mt={2} pl={1} pr={1}> <Item vertical> - <Label required> + <Label required={decision !== 'reject'}> {messagesLabel[get(formValues, 'decision', 'reject')]} </Label> <ValidatedField component={ValidatedTextArea} name="message" - validate={[required]} + validate={decision !== 'reject' ? [required] : undefined} /> </Item> </Row> )} - <Row justify="flex-end" mt={4}> + <Row justify="flex-end" mt={1} pr={1}> <Button onClick={handleSubmit} primary size="medium"> SUBMIT DECISION </Button> @@ -106,13 +110,18 @@ export default compose( modalKey: 'eic-decision', modalComponent: MultiAction, })), - withProps(({ formValues }) => ({ + withProps(({ formValues, collection }) => ({ modalTitle: eicDecisions.find( o => o.value === get(formValues, 'decision', 'publish'), ).modalTitle, modalSubtitle: eicDecisions.find( o => o.value === get(formValues, 'decision', 'publish'), ).modalSubtitle, + decision: get(formValues, 'decision'), + options: filterOptions( + eicDecisions, + get(collection, 'status', 'submitted'), + ), })), reduxForm({ form: 'eic-decision', 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-faraday-ui/src/manuscriptDetails/ManuscriptMetadata.js b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptMetadata.js index f86208b45f71994aaf8ae38bec91a88df103ed40..69d79027af59245a32fbd5d180b8555cf926ce50 100644 --- a/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptMetadata.js +++ b/packages/component-faraday-ui/src/manuscriptDetails/ManuscriptMetadata.js @@ -5,6 +5,7 @@ import { withProps } from 'recompose' import { Text, Item, + Row, ContextualBox, ManuscriptFileList, } from 'pubsweet-component-faraday-ui' @@ -24,7 +25,7 @@ const ManuscriptMetadata = ({ startExpanded transparent > - <Text mb={1} mt={1}> + <Text mb={1} mt={1} whiteSpace="pre-wrap"> {abstract} </Text> </ContextualBox> @@ -37,9 +38,30 @@ const ManuscriptMetadata = ({ label="Conflict of Interest" transparent > - <Text mb={1} mt={1}> - {get(conflicts, 'message', '')} - </Text> + <Row alignItems="center" justify="flex-start"> + <Text mb={1} mt={1}> + Conflicts of interest: + </Text> + <Text ml={1 / 2}>{get(conflicts, 'message', '')}</Text> + </Row> + {get(conflicts, 'dataAvailabilityMessage', '') && ( + <Row alignItems="center" justify="flex-start"> + <Text mb={1} mt={1}> + Data availability statment: + </Text> + <Text ml={1 / 2}> + {get(conflicts, 'dataAvailabilityMessage', '')} + </Text> + </Row> + )} + {get(conflicts, 'fundingMessage', '') && ( + <Row alignItems="center" justify="flex-start"> + <Text mb={1} mt={1}> + Funding statment: + </Text> + <Text ml={1 / 2}>{get(conflicts, 'fundingMessage', '')}</Text> + </Row> + )} </ContextualBox> </Item> )} diff --git a/packages/component-faraday-ui/src/modals/FormModal.js b/packages/component-faraday-ui/src/modals/FormModal.js index e9ab27640d22bd0783add2574425f16103b0b55a..a139df8e733baa7732263b383c066ba776220ed0 100644 --- a/packages/component-faraday-ui/src/modals/FormModal.js +++ b/packages/component-faraday-ui/src/modals/FormModal.js @@ -32,7 +32,6 @@ const FormModal = ({ countries, confirmText = 'OK', cancelText = 'Cancel', - // onSubmit, initialValues, }) => ( diff --git a/packages/component-fixture-manager/src/fixtures/collectionIDs.js b/packages/component-fixture-manager/src/fixtures/collectionIDs.js index e66bb71a232758a7de0746033ba2493745157250..a6082362b1083a2123f8013aea6451fdb4b23023 100644 --- a/packages/component-fixture-manager/src/fixtures/collectionIDs.js +++ b/packages/component-fixture-manager/src/fixtures/collectionIDs.js @@ -4,8 +4,13 @@ const chance = new Chance() module.exports = { standardCollID: chance.guid(), - collectionReviewCompletedID: chance.guid(), collectionNoInvitesID: chance.guid(), twoVersionsCollectionId: chance.guid(), + minorRevisionCollectionID: chance.guid(), + majorRevisionCollectionID: chance.guid(), + collectionReviewCompletedID: chance.guid(), + oneReviewedFragmentCollectionID: chance.guid(), noEditorRecomedationCollectionID: chance.guid(), + minorRevisionWithoutReviewCollectionID: chance.guid(), + majorRevisionWithoutReviewCollectionID: chance.guid(), } diff --git a/packages/component-fixture-manager/src/fixtures/collections.js b/packages/component-fixture-manager/src/fixtures/collections.js index 7d1df81e4f6379f9bb4d21bc243a2ed97a9e9e2d..4d92999127af2422edb3c3edd927ec744f25ea92 100644 --- a/packages/component-fixture-manager/src/fixtures/collections.js +++ b/packages/component-fixture-manager/src/fixtures/collections.js @@ -1,23 +1,35 @@ const Chance = require('chance') + const { user, handlingEditor, answerHE, noRecommendationHE, } = require('./userData') + const { fragment, fragment1, - reviewCompletedFragment, noInvitesFragment, + noInvitesFragment1, + minorRevisionWithReview, + majorRevisionWithReview, + reviewCompletedFragment, + minorRevisionWithoutReview, noEditorRecomedationFragment, } = require('./fragments') + const { standardCollID, - collectionReviewCompletedID, collectionNoInvitesID, twoVersionsCollectionId, + minorRevisionCollectionID, + majorRevisionCollectionID, + collectionReviewCompletedID, + oneReviewedFragmentCollectionID, noEditorRecomedationCollectionID, + minorRevisionWithoutReviewCollectionID, + majorRevisionWithoutReviewCollectionID, } = require('./collectionIDs') const chance = new Chance() @@ -30,6 +42,7 @@ const collections = { fragments: [fragment.id], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [fragment]), invitations: [ { id: chance.guid(), @@ -73,6 +86,7 @@ const collections = { fragments: [fragment.id], owners: [user.id], save: jest.fn(() => collections.collection), + getFragments: jest.fn(() => [fragment]), invitations: [ { id: chance.guid(), @@ -115,6 +129,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 +174,7 @@ const collections = { created: chance.timestamp(), customId: '0000001', fragments: [reviewCompletedFragment.id], + getFragments: jest.fn(() => [reviewCompletedFragment]), invitations: [ { id: chance.guid(), @@ -189,6 +205,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 +236,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 +246,7 @@ const collections = { fragments: [noEditorRecomedationFragment.id], owners: [user.id], save: jest.fn(() => collections.noEditorRecomedationCollection), + getFragments: jest.fn(() => [noEditorRecomedationFragment]), invitations: [ { id: chance.guid(), @@ -263,6 +282,183 @@ 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 }), + }, + minorRevisionCollection: { + id: minorRevisionCollectionID, + delete: jest.fn(), + title: chance.sentence(), + type: 'collection', + fragments: [minorRevisionWithReview.id, noInvitesFragment1.id], + owners: [user.id], + save: jest.fn(() => collections.minorRevisionCollection), + getFragments: jest.fn(() => [minorRevisionWithReview, noInvitesFragment1]), + invitations: [ + { + id: chance.guid(), + role: 'handlingEditor', + hasAnswer: true, + isAccepted: true, + userId: handlingEditor.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + ], + handlingEditor: { + id: handlingEditor.id, + hasAnswer: true, + isAccepted: true, + email: handlingEditor.email, + invitedOn: chance.timestamp(), + respondedOn: chance.timestamp(), + name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, + }, + technicalChecks: { + token: chance.guid(), + }, + status: 'reviewCompleted', + customId: chance.natural({ min: 999999, max: 9999999 }), + }, + minorRevisionWithoutReviewCollection: { + id: minorRevisionWithoutReviewCollectionID, + delete: jest.fn(), + title: chance.sentence(), + type: 'collection', + fragments: [minorRevisionWithoutReview.id, noInvitesFragment1.id], + owners: [user.id], + save: jest.fn(() => collections.minorRevisionWithoutReviewCollection), + getFragments: jest.fn(() => [ + minorRevisionWithoutReview, + noInvitesFragment1, + ]), + invitations: [ + { + id: chance.guid(), + role: 'handlingEditor', + hasAnswer: true, + isAccepted: true, + userId: handlingEditor.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + ], + handlingEditor: { + id: handlingEditor.id, + hasAnswer: true, + isAccepted: true, + email: handlingEditor.email, + invitedOn: chance.timestamp(), + respondedOn: chance.timestamp(), + name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, + }, + technicalChecks: { + token: chance.guid(), + }, + status: 'reviewCompleted', + customId: chance.natural({ min: 999999, max: 9999999 }), + }, + majorRevisionCollection: { + id: majorRevisionCollectionID, + delete: jest.fn(), + title: chance.sentence(), + type: 'collection', + fragments: [majorRevisionWithReview.id, reviewCompletedFragment.id], + owners: [user.id], + save: jest.fn(() => collections.minorRevisionCollection), + getFragments: jest.fn(() => [ + majorRevisionWithReview, + reviewCompletedFragment, + ]), + invitations: [ + { + id: chance.guid(), + role: 'handlingEditor', + hasAnswer: true, + isAccepted: true, + userId: handlingEditor.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + ], + handlingEditor: { + id: handlingEditor.id, + hasAnswer: true, + isAccepted: true, + email: handlingEditor.email, + invitedOn: chance.timestamp(), + respondedOn: chance.timestamp(), + name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, + }, + technicalChecks: { + token: chance.guid(), + }, + status: 'reviewCompleted', + customId: chance.natural({ min: 999999, max: 9999999 }), + }, + majorRevisionWithoutReviewCollection: { + id: majorRevisionWithoutReviewCollectionID, + delete: jest.fn(), + title: chance.sentence(), + type: 'collection', + fragments: [majorRevisionWithReview.id, noInvitesFragment1.id], + owners: [user.id], + save: jest.fn(() => collections.majorRevisionWithoutReviewCollection), + getFragments: jest.fn(() => [majorRevisionWithReview, noInvitesFragment1]), + invitations: [ + { + id: chance.guid(), + role: 'handlingEditor', + hasAnswer: true, + isAccepted: true, + userId: handlingEditor.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + ], + handlingEditor: { + id: handlingEditor.id, + hasAnswer: true, + isAccepted: true, + email: handlingEditor.email, + invitedOn: chance.timestamp(), + respondedOn: chance.timestamp(), + name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, + }, + technicalChecks: { + token: chance.guid(), + }, + status: 'reviewCompleted', + 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 026712ff89a6764daef0f2af34120f5ac7870741..475a277b97116a14d17d41e0c92bbdb6daba8c75 100644 --- a/packages/component-fixture-manager/src/fixtures/fragments.js +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -99,6 +99,69 @@ const fragments = { createdOn: chance.timestamp(), updatedOn: chance.timestamp(), }, + { + recommendation: 'publish', + 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: 1542361074012, + updatedOn: chance.timestamp(), + }, + { + recommendation: 'return-to-handling-editor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: admin.id, + createdOn: 1542361115749, + updatedOn: chance.timestamp(), + }, + { + recommendation: 'publish', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditor.id, + createdOn: 1542361115750, + updatedOn: chance.timestamp(), + }, { recommendation: 'publish', recommendationType: 'editorRecommendation', @@ -117,6 +180,27 @@ const fragments = { ], id: chance.guid(), userId: admin.id, + createdOn: 1542361115751, + updatedOn: chance.timestamp(), + }, + { + recommendation: 'revision', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: admin.id, createdOn: chance.timestamp(), updatedOn: chance.timestamp(), }, @@ -277,6 +361,7 @@ const fragments = { invitedOn: chance.timestamp(), isAccepted: true, respondedOn: chance.timestamp(), + reviewerNumber: 2, }, { id: chance.guid(), @@ -457,6 +542,48 @@ const fragments = { updatedOn: chance.timestamp(), submittedOn: chance.timestamp(), }, + { + recommendation: 'publish', + 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: 1542361074012, + updatedOn: chance.timestamp(), + }, + { + recommendation: 'return-to-handling-editor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: admin.id, + createdOn: 1542361115749, + updatedOn: chance.timestamp(), + }, ], authors: [ { diff --git a/packages/component-fixture-manager/src/fixtures/teamIDs.js b/packages/component-fixture-manager/src/fixtures/teamIDs.js index 162cdb391585ed6789018579dbb1469271f8d2f2..1de0429354609073c2809e77c3f470ccc8094562 100644 --- a/packages/component-fixture-manager/src/fixtures/teamIDs.js +++ b/packages/component-fixture-manager/src/fixtures/teamIDs.js @@ -5,9 +5,10 @@ const chance = new Chance() module.exports = { heTeamID: chance.guid(), revTeamID: chance.guid(), + rev1TeamID: chance.guid(), authorTeamID: chance.guid(), + majorRevisionHeTeamID: chance.guid(), revRecommendationTeamID: chance.guid(), - rev1TeamID: chance.guid(), heNoRecommendationTeamID: chance.guid(), revNoEditorRecommendationTeamID: chance.guid(), } diff --git a/packages/component-fixture-manager/src/fixtures/teams.js b/packages/component-fixture-manager/src/fixtures/teams.js index dbd51a4aca783c1315e45fe3749b7c2bd1f8145c..79b650069ccefcad84384488bc42b4294186fde9 100644 --- a/packages/component-fixture-manager/src/fixtures/teams.js +++ b/packages/component-fixture-manager/src/fixtures/teams.js @@ -5,21 +5,28 @@ const fragments = require('./fragments') const { heTeamID, revTeamID, + rev1TeamID, authorTeamID, + majorRevisionHeTeamID, revRecommendationTeamID, - rev1TeamID, heNoRecommendationTeamID, revNoEditorRecommendationTeamID, } = require('./teamIDs') const { submittingAuthor } = require('./userData') -const { collection, noEditorRecomedationCollection } = collections +const { + collection, + majorRevisionCollection, + noEditorRecomedationCollection, +} = collections + const { fragment, - reviewCompletedFragment, fragment1, + reviewCompletedFragment, noEditorRecomedationFragment, } = fragments + const { handlingEditor, reviewer, @@ -148,5 +155,22 @@ const teams = { updateProperties: jest.fn(() => teams.revNoEditorRecommendationTeam), id: revNoEditorRecommendationTeamID, }, + majorRevisionHeTeam: { + teamType: { + name: 'handlingEditor', + permissions: 'handlingEditor', + }, + group: 'handlingEditor', + name: 'HandlingEditor', + object: { + type: 'collection', + id: majorRevisionCollection.id, + }, + members: [handlingEditor.id], + save: jest.fn(() => teams.majorRevisionHeTeam), + delete: jest.fn(), + updateProperties: jest.fn(() => teams.majorRevisionHeTeam), + id: majorRevisionHeTeamID, + }, } module.exports = teams diff --git a/packages/component-fixture-manager/src/fixtures/users.js b/packages/component-fixture-manager/src/fixtures/users.js index 0ec653aab24e414415d7a11904367c2fdf94ede3..2b03c59776ada663e00e2a9ac58f8c73a4060db7 100644 --- a/packages/component-fixture-manager/src/fixtures/users.js +++ b/packages/component-fixture-manager/src/fixtures/users.js @@ -5,9 +5,10 @@ const chance = new Chance() const { heTeamID, revTeamID, + rev1TeamID, authorTeamID, + majorRevisionHeTeamID, revRecommendationTeamID, - rev1TeamID, heNoRecommendationTeamID, revNoEditorRecommendationTeamID, } = require('./teamIDs') @@ -24,7 +25,7 @@ users = keys.reduce((obj, item) => { teams = [heTeamID] break case 'handlingEditor': - teams = [heTeamID] + teams = [heTeamID, majorRevisionHeTeamID] break case 'noRecommendationHE': teams = [heNoRecommendationTeamID] diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index 6e05d9a584f61eddc5a63a7ad309ac81bea1edb6..e95d5e8666a94464384fac61f693d199fe3c154b 100644 --- a/packages/component-helper-service/src/services/Collection.js +++ b/packages/component-helper-service/src/services/Collection.js @@ -1,4 +1,17 @@ -const { findLast, get } = require('lodash') +const config = require('config') +const { v4 } = require('uuid') +const logger = require('@pubsweet/logger') + +const { + findLast, + isEmpty, + maxBy, + get, + flatMap, + last, + has, + set, +} = require('lodash') const Fragment = require('./Fragment') @@ -33,25 +46,6 @@ class Collection { return this.updateStatus({ newStatus }) } - async updateFinalStatusByRecommendation({ recommendation }) { - let newStatus - switch (recommendation) { - case 'reject': - newStatus = 'rejected' - break - case 'publish': - newStatus = 'accepted' - break - case 'return-to-handling-editor': - newStatus = 'reviewCompleted' - break - default: - break - } - - await this.updateStatus({ newStatus }) - } - async updateStatus({ newStatus }) { this.collection.status = newStatus await this.collection.save() @@ -89,26 +83,33 @@ class Collection { await this.updateStatus({ newStatus: 'heAssigned' }) } - async updateStatusOnRecommendation({ isEditorInChief, recommendation }) { - if (isEditorInChief) { - if (recommendation === 'return-to-handling-editor') { - return this.updateStatus({ newStatus: 'reviewCompleted' }) - } - return this.updateFinalStatusByRecommendation({ - recommendation, - }) - } - return this.updateStatusByRecommendation({ - recommendation, - isHandlingEditor: true, - }) - } - getHELastName() { const [firstName, lastName] = this.collection.handlingEditor.name.split(' ') return lastName || firstName } + async getReviewerNumber({ userId }) { + const allCollectionFragments = await this.collection.getFragments() + const allCollectionInvitations = flatMap( + allCollectionFragments, + fragment => fragment.invitations, + ).filter(Boolean) + + 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 +127,21 @@ 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 ( + ['major', 'revision'].includes(lastEditorRecommendation.recommendation) + ) { return fragmentHelper.hasReviewReport() } + + return false } async getAllFragments({ FragmentModel }) { @@ -146,6 +151,92 @@ class Collection { ), ) } + + isLatestVersion(fragmentId) { + return last(this.collection.fragments) === fragmentId + } + + hasEQA() { + const technicalChecks = get(this.collection, 'technicalChecks', {}) + return has(technicalChecks, 'eqa') + } + + async setTechnicalChecks() { + set(this.collection, 'technicalChecks.token', v4()) + set(this.collection, 'technicalChecks.eqa', false) + await this.collection.save() + } + + async sendToMTS({ FragmentModel, UserModel, fragmentHelper }) { + await Promise.all( + this.collection.fragments.map(async fragmentId => { + const fragment = await FragmentModel.find(fragmentId) + + let fragmentUsers = [] + try { + fragmentUsers = await fragmentHelper.getReviewersAndEditorsData({ + collection: this.collection, + UserModel, + }) + + await sendMTSPackage({ + collection: this.collection, + fragment, + isEQA: true, + fragmentUsers, + }) + } catch (e) { + logger.error(e) + } + }), + ).catch(e => { + throw new Error('Something went wrong.') + }) + } + + async removeTechnicalChecks() { + this.collection.technicalChecks = {} + await this.collection.save() + } + + hasHandlingEditor() { + return has(this.collection, 'handlingEditor') + } + + async addFragment(newFragmentId) { + this.collection.fragments.push(newFragmentId) + await this.collection.save() + } +} + +const sendMTSPackage = async ({ + fragment, + collection, + isEQA = false, + fragmentUsers = [], +}) => { + const s3Config = get(config, 'pubsweet-component-aws-s3', {}) + const mtsConfig = get(config, 'mts-service', {}) + const { sendPackage } = require('pubsweet-component-mts-package') + + const { journal, xmlParser, ftp } = mtsConfig + const packageFragment = { + ...fragment, + metadata: { + ...fragment.metadata, + customId: collection.customId, + }, + } + + await sendPackage({ + isEQA, + s3Config, + fragmentUsers, + ftpConfig: ftp, + config: journal, + options: xmlParser, + fragment: packageFragment, + }) } module.exports = Collection diff --git a/packages/component-helper-service/src/services/Fragment.js b/packages/component-helper-service/src/services/Fragment.js index a11783f269aebde945aefbef266d7d3d3fb09e88..8cdd85f609bc51bbf045a3fc3007746b7bf09470 100644 --- a/packages/component-helper-service/src/services/Fragment.js +++ b/packages/component-helper-service/src/services/Fragment.js @@ -1,4 +1,5 @@ -const { get, remove } = require('lodash') +const { get, remove, findLast, pick, chain, omit } = require('lodash') + const config = require('config') const User = require('./User') @@ -123,13 +124,12 @@ class Fragment { getLatestHERequestToRevision() { const { fragment: { recommendations = [] } } = this - return recommendations - .filter( - rec => - rec.recommendationType === 'editorRecommendation' && - (rec.recommendation === 'minor' || rec.recommendation === 'major'), - ) - .sort((a, b) => b.createdOn - a.createdOn)[0] + return findLast( + recommendations, + rec => + rec.recommendationType === 'editorRecommendation' && + (rec.recommendation === 'minor' || rec.recommendation === 'major'), + ) } async getReviewers({ UserModel, type }) { @@ -150,6 +150,18 @@ class Fragment { ) } + canHEMakeAnotherRecommendation(lastHERecommendation) { + const { fragment: { recommendations = [] } } = this + + const returnToHERecommendation = findLast( + recommendations, + r => r.recommendation === 'return-to-handling-editor', + ) + + if (!returnToHERecommendation) return false + return returnToHERecommendation.createdOn > lastHERecommendation.createdOn + } + async getReviewersAndEditorsData({ collection, UserModel }) { const { invitations = [], @@ -210,6 +222,71 @@ class Fragment { return revAndEditorData } + + async addRecommendation(newRecommendation) { + this.fragment.recommendations = this.fragment.recommendations || [] + + this.fragment.recommendations.push(newRecommendation) + await this.fragment.save() + } + + async addRevision() { + this.fragment.revision = pick(this.fragment, [ + 'authors', + 'files', + 'metadata', + ]) + await this.fragment.save() + } + + hasReviewers() { + const { fragment: invitations = [] } = this + return invitations.length > 0 + } + + getLatestUserRecommendation(userId) { + return findLast(this.fragment.recommendations, r => r.userId === userId) + } + + getLatestRecommendation() { + return chain(this.fragment) + .get('recommendations', []) + .last() + .value() + } + + async createFragmentFromRevision(FragmentModel) { + const newFragmentBody = { + ...omit(this.fragment, ['revision', 'recommendations', 'id']), + ...this.fragment.revision, + invitations: this.getInvitations({ + isAccepted: true, + type: 'submitted', + }), + version: this.fragment.version + 1, + created: new Date(), + } + + let newFragment = new FragmentModel(newFragmentBody) + newFragment = await newFragment.save() + + return newFragment + } + + async removeRevision() { + delete this.fragment.revision + await this.fragment.save() + } + + getLatestEiCRequestToRevision() { + const { fragment: { recommendations = [] } } = this + return findLast( + recommendations, + rec => + rec.recommendationType === 'editorRecommendation' && + rec.recommendation === 'revision', + ) + } } module.exports = Fragment diff --git a/packages/component-helper-service/src/services/User.js b/packages/component-helper-service/src/services/User.js index dbd0eb1f2bf7b6e5995a27df258efbb6a532fb3d..1878092704ad027560176e161e3a526367ea3363 100644 --- a/packages/component-helper-service/src/services/User.js +++ b/packages/component-helper-service/src/services/User.js @@ -67,7 +67,7 @@ class User { async updateUserTeams({ userId, teamId }) { const user = await this.UserModel.find(userId) user.teams.push(teamId) - user.save() + await user.save() } async getActiveAuthors({ fragmentAuthors }) { 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-helper-service/src/tests/fragment.test.js b/packages/component-helper-service/src/tests/fragment.test.js index 26598cfe8bd73fc40a8966a03ab51aa3552f3668..ec7fbb6073f311f844c2ff574c936dfe00b9ff35 100644 --- a/packages/component-helper-service/src/tests/fragment.test.js +++ b/packages/component-helper-service/src/tests/fragment.test.js @@ -15,6 +15,8 @@ const { recommendations: configRecommendations } = config const acceptedReviewerId = chance.guid() const submittedReviewerId1 = chance.guid() const submittedReviewerId2 = chance.guid() +const handlingEditorId = chance.guid() +const editorInChiefId = chance.guid() const fragment = { invitations: [ { @@ -281,4 +283,94 @@ describe('Fragment helper', () => { } }) }) + describe('canHEMakeAnotherRecommendation', () => { + it('should return true when He makes a recommendation after EIC decision was to return to HE', async () => { + testFragment.recommendations = [ + { + recommendation: 'publish', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditorId, + createdOn: 1542361074012, + updatedOn: chance.timestamp(), + }, + { + recommendation: 'return-to-handling-editor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: editorInChiefId, + createdOn: 1542361115749, + updatedOn: chance.timestamp(), + }, + ] + const fragmentHelper = new Fragment({ fragment: testFragment }) + const latestUserRecommendation = fragmentHelper.getLatestUserRecommendation( + handlingEditorId, + ) + + const canHEMakeAnotherRecommendation = await fragmentHelper.canHEMakeAnotherRecommendation( + latestUserRecommendation, + ) + expect(canHEMakeAnotherRecommendation).toBe(true) + }) + it('should return false when He makes another recommendation', async () => { + testFragment.recommendations = [ + { + recommendation: 'publish', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: true, + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditorId, + createdOn: 1542361074012, + updatedOn: chance.timestamp(), + }, + ] + const fragmentHelper = new Fragment({ fragment: testFragment }) + const latestUserRecommendation = fragmentHelper.getLatestUserRecommendation( + handlingEditorId, + ) + const canHEMakeAnotherRecommendation = await fragmentHelper.canHEMakeAnotherRecommendation( + latestUserRecommendation, + ) + expect(canHEMakeAnotherRecommendation).toBe(false) + }) + }) }) 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..c93402add18d6180d6f47bb688bd2b77fb7a1f4f 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/delete.js +++ b/packages/component-invite/src/routes/collectionsInvitations/delete.js @@ -1,8 +1,18 @@ +const config = require('config') + const { Team, + Fragment, 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 +66,107 @@ 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 fragmentHelper = new Fragment({ fragment }) + + 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 + } + + const reviewers = [ + ...(await fragmentHelper.getReviewers({ + UserModel, + type: 'accepted', + })), + ...(await fragmentHelper.getReviewers({ + UserModel, + type: 'submitted', + })), + ] + + fragment.invitations = [] + fragment.recommendations = [] + fragment.revision && delete fragment.revision + await fragment.save() + + notifications.notifyInvitedHEWhenRemoved({ + models, + collection, + invitedHE: user, + baseUrl: services.getBaseUrl(req), + }) + + notifications.notifyReviewersWhenHERemoved({ + models, + collection, + reviewers, + 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..66a72b51a80df466da1511014dc702a0132b035b 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,30 @@ 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 = `We had to replace the handling editor of your manuscript ${titleText}. We apologise for any inconvenience, but it was necessary in order to move your manuscript forward.<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 + case 'reviewer-he-removed': + hasIntro = true + hasLink = false + hasSignature = true + paragraph = `We had to replace the handling editor of the manuscript "${titleText}". We apologise for any inconvenience this may cause.<br/><br/> + If you have started the review process please email the content to ${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..d1378dca273dcb3be93426c1841c71f4685e085d 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,136 @@ 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() + }, + notifyReviewersWhenHERemoved: async ({ + baseUrl, + collection, + reviewers, + 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: 'reviewer-he-removed', + }) + + reviewers.forEach(reviewer => { + const email = new Email({ + type: 'user', + toUser: { + email: reviewer.email, + name: reviewer.lastName, + }, + fromEmail: `${eicName} <${staffEmail}>`, + content: { + subject: `${customId}: The handling editor of a manuscript that you were reviewing was changed`, + paragraph, + signatureName: eicName, + signatureJournal: journalName, + unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { + id: reviewer.id, + token: reviewer.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + }) + }, sendEiCEmail: async ({ reason, baseUrl, diff --git a/packages/component-invite/src/routes/collectionsInvitations/post.js b/packages/component-invite/src/routes/collectionsInvitations/post.js index 7d404e0cec4a91d3a74808722332a7266963aa78..8e354cf84a9b9b912b10980435ea169741ff994e 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/post.js +++ b/packages/component-invite/src/routes/collectionsInvitations/post.js @@ -50,7 +50,6 @@ module.exports = models => async (req, res) => { error: 'Unauthorized.', }) - // check collection status if (!['submitted', 'heInvited'].includes(collection.status)) { return res.status(400).json({ error: `Cannot invite HE while collection is in the status: ${ diff --git a/packages/component-invite/src/routes/fragmentsInvitations/post.js b/packages/component-invite/src/routes/fragmentsInvitations/post.js index 0e45b83faac29aef7b05b2ca588cf07b6dc7fb0e..df347226ef738bab6131bf4f1f63738fe0310e9f 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/post.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/post.js @@ -13,18 +13,8 @@ const emailInvitations = require('./emails/invitations') const { last } = require('lodash') module.exports = models => async (req, res) => { - const { email, role, firstName, lastName, affiliation, country } = req.body - - if ( - !services.checkForUndefinedParams( - email, - role, - firstName, - lastName, - affiliation, - country, - ) - ) { + const { email, role } = req.body + if (!services.checkForUndefinedParams(email, role)) { res.status(400).json({ error: 'Missing parameters.' }) return } @@ -112,6 +102,17 @@ module.exports = models => async (req, res) => { await fragment.save() resend = true } else { + const { firstName, lastName, affiliation, country } = req.body + if ( + !services.checkForUndefinedParams( + firstName, + lastName, + affiliation, + country, + ) + ) { + res.status(400).json({ error: 'Missing parameters.' }) + } invitation = await invitationHelper.createInvitation({ parentObject: fragment, }) 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-invite/src/tests/collectionsInvitations/post.test.js b/packages/component-invite/src/tests/collectionsInvitations/post.test.js index de48dc4f84033d9090d303bd4ce1563c1298a992..94dc618a3165e9535d1cb6fce0d4fa92e23eb354 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/post.test.js +++ b/packages/component-invite/src/tests/collectionsInvitations/post.test.js @@ -192,7 +192,36 @@ describe('Post collections invitations route handler', () => { }, }) - // expect(res.statusCode).toBe(200) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + + expect(data.error).toEqual( + `Cannot invite HE while collection is in the status: ${ + collection.status + }.`, + ) + }) + it('should return an error when the collection is in the revision requested status', async () => { + const { user, editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + collection.status = 'revisionRequested' + + body = { + email: user.email, + role: 'handlingEditor', + } + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + }, + }) + + expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual( diff --git a/packages/component-invite/src/tests/fragmentsInvitations/post.test.js b/packages/component-invite/src/tests/fragmentsInvitations/post.test.js index 0683f0aaf927ed7e6b810bd1103aaa75b4267955..cf4b990bb539ca664bb997a79bd136142908ac57 100644 --- a/packages/component-invite/src/tests/fragmentsInvitations/post.test.js +++ b/packages/component-invite/src/tests/fragmentsInvitations/post.test.js @@ -52,6 +52,7 @@ describe('Post fragments invitations route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Missing parameters.') }) + it('should return success when a reviewer is invited', async () => { const { user, editorInChief } = testFixtures.users const { collection } = testFixtures.collections @@ -78,6 +79,61 @@ describe('Post fragments invitations route handler', () => { const data = JSON.parse(res._getData()) expect(data.role).toEqual(body.role) }) + + it('should return success when resending an invitation to an existing reviewer', async () => { + const { editorInChief, reviewer } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const body = { + email: reviewer.email, + role: 'reviewer', + isPublons: false, + } + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.role).toEqual(body.role) + }) + + it('should return an error when resending an invitation to reviewer and the params are missing.', async () => { + const { editorInChief, reviewer } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const body = { + email: reviewer.email, + } + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Missing parameters.') + }) + it('should return an error when inviting his self', async () => { const { editorInChief } = testFixtures.users body.email = editorInChief.email diff --git a/packages/component-manuscript-manager/src/notifications/emailCopy.js b/packages/component-manuscript-manager/src/notifications/emailCopy.js index 3aa154fcdd356c251d191cf3d38b02778e1379cf..67bfe04a10871271dec8db264da90e0b619fb7b0 100644 --- a/packages/component-manuscript-manager/src/notifications/emailCopy.js +++ b/packages/component-manuscript-manager/src/notifications/emailCopy.js @@ -11,6 +11,7 @@ const getEmailCopy = ({ comments = '', targetUserName = '', eicName = 'Editor in Chief', + expectedDate = new Date(), }) => { let paragraph let hasLink = true @@ -131,6 +132,29 @@ const getEmailCopy = ({ paragraph = `We regret to inform you that ${titleText} has been returned with comments. Please click the link below to access the manuscript.<br/><br/> Comments: ${comments}<br/><br/>` break + case 'submitted-reviewers-after-revision': + paragraph = `The authors have submitted a new version of ${titleText}, which you reviewed for ${journalName}.<br/><br/> + As you reviewed the previous version of this manuscript, I would be grateful if you could review this revision and submit a new report by ${expectedDate}. + To download the updated PDF and proceed with the review process, please visit the manuscript details page.<br/><br/> + Thank you again for reviewing for ${journalName}.` + break + case 'he-new-version-submitted': + hasIntro = false + hasSignature = false + paragraph = `The authors of ${titleText} have submitted a revised version. <br/><br/> + To review this new submission and proceed with the review process, please visit the manuscript details page.` + break + case 'eic-revision-published': + hasIntro = false + hasSignature = false + paragraph = `The authors of ${titleText} have submitted a revised version. <br/><br/> + To review this new submission and proceed with the review process, please visit the manuscript details page.` + break + case 'author-request-to-revision-from-eic': + paragraph = `In order for ${titleText} to proceed to the review process, there needs to be a revision. <br/><br/> + ${comments}<br/><br/> + For more information about what is required, please click the link below.<br/><br/>` + break default: throw new Error(`The ${emailType} email type is not defined.`) } diff --git a/packages/component-manuscript-manager/src/notifications/notification.js b/packages/component-manuscript-manager/src/notifications/notification.js index 506e9ba191a51fd7a1e34142dac3f5a03895de23..c80f5c29a2efa52a545033d57ca6929d5365ccd5 100644 --- a/packages/component-manuscript-manager/src/notifications/notification.js +++ b/packages/component-manuscript-manager/src/notifications/notification.js @@ -407,6 +407,53 @@ class Notification { return email.sendEmail() } + async notifySAWhenEiCRequestsRevision() { + const { + eicName, + submittingAuthor, + titleText, + } = await this._getNotificationProperties() + + const authorNoteText = helpers.getPrivateNoteTextForAuthor({ + newRecommendation: this.newRecommendation, + }) + + const { paragraph, ...bodyProps } = getEmailCopy({ + emailType: 'author-request-to-revision-from-eic', + titleText, + comments: authorNoteText, + }) + + const email = new Email({ + type: 'user', + toUser: { + email: submittingAuthor.email, + name: submittingAuthor.lastName, + }, + fromEmail: `${eicName} <${staffEmail}>`, + content: { + subject: `${this.collection.customId}: Revision requested`, + paragraph, + signatureName: eicName, + ctaText: 'MANUSCRIPT DETAILS', + signatureJournal: journalName, + unsubscribeLink: services.createUrl(this.baseUrl, unsubscribeSlug, { + id: submittingAuthor.id, + token: submittingAuthor.accessTokens.unsubscribe, + }), + ctaLink: services.createUrl( + this.baseUrl, + `/projects/${this.collection.id}/versions/${ + this.fragment.id + }/details`, + ), + }, + bodyProps, + }) + + return email.sendEmail() + } + async notifyReviewersWhenHEMakesRecommendation() { const { eicName, @@ -629,6 +676,144 @@ class Notification { }) } + async notifyEditorInChiefWhenAuthorSubmitsRevision(newFragment) { + const { titleText } = await this._getNotificationProperties() + + const userHelper = new User({ UserModel: this.UserModel }) + const editors = await userHelper.getEditorsInChief() + + const { paragraph, ...bodyProps } = getEmailCopy({ + titleText, + emailType: 'eic-revision-published', + }) + + editors.forEach(eic => { + const email = new Email({ + type: 'user', + fromEmail: `${journalName} <${staffEmail}>`, + toUser: { + email: eic.email, + }, + content: { + subject: `${this.collection.customId}: Revision submitted`, + paragraph, + signatureName: '', + signatureJournal: journalName, + ctaLink: services.createUrl( + this.baseUrl, + `/projects/${this.collection.id}/versions/${ + newFragment.id + }/details`, + ), + ctaText: 'MANUSCRIPT DETAILS', + unsubscribeLink: services.createUrl(this.baseUrl, unsubscribeSlug, { + id: eic.id, + token: eic.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + }) + } + + async notifyReviewersWhenAuthorSubmitsMajorRevision(newFragmentId) { + const { fragmentHelper } = await this._getNotificationProperties() + const { collection, UserModel } = this + + const handlingEditor = get(collection, 'handlingEditor') + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor, + }) + + const reviewers = await fragmentHelper.getReviewers({ + UserModel, + type: 'submitted', + }) + + const { paragraph, ...bodyProps } = getEmailCopy({ + emailType: 'submitted-reviewers-after-revision', + titleText: `the manuscript titled "${parsedFragment.title}"`, + expectedDate: services.getExpectedDate({ daysExpected: 14 }), + }) + + reviewers.forEach(reviewer => { + const email = new Email({ + type: 'user', + fromEmail: `${handlingEditor.name} <${staffEmail}>`, + toUser: { + email: reviewer.email, + name: `${reviewer.lastName}`, + }, + content: { + subject: `${ + collection.customId + }: A manuscript you reviewed has been revised`, + paragraph, + signatureName: handlingEditor.name, + signatureJournal: journalName, + ctaLink: services.createUrl( + this.baseUrl, + `/projects/${collection.id}/versions/${newFragmentId}/details`, + ), + ctaText: 'MANUSCRIPT DETAILS', + unsubscribeLink: services.createUrl(this.baseUrl, unsubscribeSlug, { + id: reviewer.id, + token: reviewer.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + }) + } + + async notifyHandlingEditorWhenAuthorSubmitsRevision(newFragment) { + const { collection, UserModel } = this + + const handlingEditor = get(collection, 'handlingEditor') + + const fragmentHelper = new Fragment({ fragment: newFragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor, + }) + + const { paragraph, ...bodyProps } = getEmailCopy({ + emailType: 'he-new-version-submitted', + titleText: `the manuscript titled "${parsedFragment.title}"`, + }) + + const heUser = await UserModel.find(handlingEditor.id) + + const email = new Email({ + type: 'user', + fromEmail: `${journalName} <${staffEmail}>`, + toUser: { + email: heUser.email, + }, + content: { + subject: `${collection.customId}: Revision submitted`, + paragraph, + signatureName: '', + signatureJournal: journalName, + ctaLink: services.createUrl( + this.baseUrl, + `/projects/${collection.id}/versions/${newFragment.id}/details`, + ), + ctaText: 'MANUSCRIPT DETAILS', + unsubscribeLink: services.createUrl(this.baseUrl, unsubscribeSlug, { + id: heUser.id, + token: heUser.accessTokens.unsubscribe, + }), + }, + bodyProps, + }) + + return email.sendEmail() + } + async _getNotificationProperties() { const fragmentHelper = new Fragment({ fragment: this.fragment }) const parsedFragment = await fragmentHelper.getFragmentData({ diff --git a/packages/component-manuscript-manager/src/routes/fragments/notifications/emailCopy.js b/packages/component-manuscript-manager/src/routes/fragments/notifications/emailCopy.js index ecfadb0a4ae20fb38e8e9511b10120fb33fabfaf..e6ab3f502678fcf09ecd40557b7d9b63607f9fea 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/notifications/emailCopy.js +++ b/packages/component-manuscript-manager/src/routes/fragments/notifications/emailCopy.js @@ -5,21 +5,9 @@ const journalName = config.get('journal.name') const getEmailCopy = ({ emailType, titleText, expectedDate, customId }) => { let paragraph const hasLink = true - let hasIntro = true - let hasSignature = true + const hasIntro = true + const hasSignature = true switch (emailType) { - case 'he-new-version-submitted': - hasIntro = false - hasSignature = false - paragraph = `The authors of ${titleText} have submitted a revised version. <br/><br/> - To review this new submission and proceed with the review process, please visit the manuscript details page.` - break - case 'submitted-reviewers-after-revision': - paragraph = `The authors have submitted a new version of ${titleText}, which you reviewed for ${journalName}.<br/><br/> - As you reviewed the previous version of this manuscript, I would be grateful if you could review this revision and submit a new report by ${expectedDate}. - To download the updated PDF and proceed with the review process, please visit the manuscript details page.<br/><br/> - Thank you again for reviewing for ${journalName}.` - break case 'eqs-manuscript-submitted': paragraph = `Manuscript ID ${customId} has been submitted and a package has been sent. Please click on the link below to either approve or reject the manuscript:` break diff --git a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js index dcbe3c22f7278a55af53a9e9477f80860ea4609f..bc2841276ba9300ce4c39b51068989b5b879b0ce 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js @@ -15,106 +15,11 @@ const unsubscribeSlug = config.get('unsubscribe.url') const { getEmailCopy } = require('./emailCopy') module.exports = { - async sendHandlingEditorEmail({ baseUrl, fragment, UserModel, collection }) { - const fragmentHelper = new Fragment({ fragment }) - const handlingEditor = get(collection, 'handlingEditor') - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor, - }) - - const { paragraph, ...bodyProps } = getEmailCopy({ - emailType: 'he-new-version-submitted', - titleText: `the manuscript titled "${parsedFragment.title}"`, - }) - - const heUser = await UserModel.find(handlingEditor.id) - - const email = new Email({ - type: 'user', - fromEmail: `${journalName} <${staffEmail}>`, - toUser: { - email: heUser.email, - }, - content: { - subject: `${collection.customId}: Revision submitted`, - paragraph, - signatureName: '', - signatureJournal: journalName, - ctaLink: services.createUrl( - baseUrl, - `/projects/${collection.id}/versions/${fragment.id}/details`, - ), - ctaText: 'MANUSCRIPT DETAILS', - unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { - id: heUser.id, - token: heUser.accessTokens.unsubscribe, - }), - }, - bodyProps, - }) - - return email.sendEmail() - }, - - async sendReviewersEmail({ - baseUrl, - fragment, - UserModel, - collection, - previousVersion, - }) { - const fragmentHelper = new Fragment({ fragment: previousVersion }) - const handlingEditor = get(collection, 'handlingEditor') - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor, - }) - - const reviewers = await fragmentHelper.getReviewers({ - UserModel, - type: 'submitted', - }) - - const { paragraph, ...bodyProps } = getEmailCopy({ - emailType: 'submitted-reviewers-after-revision', - titleText: `the manuscript titled "${parsedFragment.title}"`, - expectedDate: services.getExpectedDate({ daysExpected: 14 }), - }) - - reviewers.forEach(reviewer => { - const email = new Email({ - type: 'user', - fromEmail: `${handlingEditor.name} <${staffEmail}>`, - toUser: { - email: reviewer.email, - name: `${reviewer.lastName}`, - }, - content: { - subject: `${ - collection.customId - }: A manuscript you reviewed has been revised`, - paragraph, - signatureName: handlingEditor.name, - signatureJournal: journalName, - ctaLink: services.createUrl( - baseUrl, - `/projects/${collection.id}/versions/${fragment.id}/details`, - ), - ctaText: 'MANUSCRIPT DETAILS', - unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { - id: reviewer.id, - token: reviewer.accessTokens.unsubscribe, - }), - }, - bodyProps, - }) - - return email.sendEmail() - }) - }, - async sendEQSEmail({ baseUrl, fragment, UserModel, collection }) { const userHelper = new User({ UserModel }) const eicName = await userHelper.getEiCName() + const fragmentHelper = new Fragment({ fragment }) + const { title } = await fragmentHelper.getFragmentData({}) const { paragraph, ...bodyProps } = getEmailCopy({ emailType: 'eqs-manuscript-submitted', @@ -134,6 +39,7 @@ module.exports = { signatureName: eicName, signatureJournal: journalName, ctaLink: services.createUrl(baseUrl, config.get('eqs-decision.url'), { + title, collectionId: collection.id, customId: collection.customId, token: collection.technicalChecks.token, @@ -231,7 +137,7 @@ module.exports = { firstName: author.firstName, lastName: author.lastName, affiliation: author.affiliation, - title: author.title, + title: author.title.toLowerCase(), country: author.country, }) email.content.ctaText = 'CONFIRM ACCOUNT' diff --git a/packages/component-manuscript-manager/src/routes/fragments/patch.js b/packages/component-manuscript-manager/src/routes/fragments/patch.js index acd037254ddb9ada6417d99980a2a30588799d08..4f975c391c34232517734c2c3e920ad4bfefe7ca 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragments/patch.js @@ -1,14 +1,16 @@ -const { union, omit } = require('lodash') - const { Team, + User, services, Fragment, Collection, authsome: authsomeHelper, } = require('pubsweet-component-helper-service') -const notifications = require('./notifications/notifications') +const Notification = require('../../notifications/notification') + +const eicRequestRevision = require('./strategies/eicRequestRevision') +const heRequestRevision = require('./strategies/heRequestRevision') module.exports = models => async (req, res) => { const { collectionId, fragmentId } = req.params @@ -24,7 +26,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.', }) } @@ -41,100 +44,35 @@ module.exports = models => async (req, res) => { const collectionHelper = new Collection({ collection }) const fragmentHelper = new Fragment({ fragment }) - const heRecommendation = fragmentHelper.getLatestHERequestToRevision() - if (!heRecommendation) { - return res.status(400).json({ - error: 'No Handling Editor request to revision has been found.', - }) - } - - const newFragmentBody = { - ...omit(fragment, ['revision', 'recommendations', 'id']), - ...fragment.revision, - invitations: fragmentHelper.getInvitations({ - isAccepted: true, - type: 'submitted', - }), - version: fragment.version + 1, - created: new Date(), - } - - let newFragment = new models.Fragment(newFragmentBody) - newFragment = await newFragment.save() - const teamHelper = new Team({ - TeamModel: models.Team, - collectionId, - fragmentId: newFragment.id, - }) - delete fragment.revision - fragment.save() - - if (heRecommendation.recommendation === 'major') { - const reviewerIds = newFragment.invitations.map(inv => inv.userId) - - teamHelper.createTeam({ - role: 'reviewer', - members: reviewerIds, - objectType: 'fragment', - }) - } else { - delete newFragment.invitations - await newFragment.save() - } - - const authorIds = newFragment.authors.map(auth => { - const { id } = auth - return id - }) - - let authorsTeam = await teamHelper.getTeam({ - role: 'author', - objectType: 'fragment', - }) + const userHelper = new User({ UserModel: models.User }) - if (!authorsTeam) { - authorsTeam = await teamHelper.createTeam({ - role: 'author', - members: authorIds, - objectType: 'fragment', - }) - } else { - authorsTeam.members = union(authorsTeam.members, authorIds) - await authorsTeam.save() + const strategies = { + he: heRequestRevision, + eic: eicRequestRevision, } - const fragments = await collectionHelper.getAllFragments({ - FragmentModel: models.Fragment, - }) - - await collectionHelper.updateStatusByRecommendation({ - recommendation: heRecommendation.recommendation, - fragments, - }) - - newFragment.submitted = Date.now() - newFragment = await newFragment.save() - collection.fragments.push(newFragment.id) - collection.save() + const role = collection.handlingEditor ? 'he' : 'eic' - notifications.sendHandlingEditorEmail({ - baseUrl: services.getBaseUrl(req), - fragment: newFragment, - UserModel: models.User, + const notification = new Notification({ + fragment, collection, + UserModel: models.User, + baseUrl: services.getBaseUrl(req), }) - if (heRecommendation.recommendation === 'major') { - notifications.sendReviewersEmail({ - baseUrl: services.getBaseUrl(req), - fragment: newFragment, - UserModel: models.User, - collection, - previousVersion: fragment, + try { + const newFragment = await strategies[role].execute({ + models, + userHelper, + notification, + fragmentHelper, + collectionHelper, + TeamHelper: Team, }) + return res.status(200).json(newFragment) + } catch (e) { + return res.status(400).json({ error: e.message }) } - - return res.status(200).json(newFragment) } catch (e) { const notFoundError = await services.handleNotFoundError(e, 'Item') return res.status(notFoundError.status).json({ diff --git a/packages/component-manuscript-manager/src/routes/fragments/strategies/eicRequestRevision.js b/packages/component-manuscript-manager/src/routes/fragments/strategies/eicRequestRevision.js new file mode 100644 index 0000000000000000000000000000000000000000..eb99d655a56a86d441b9746b51aaac4af9810501 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragments/strategies/eicRequestRevision.js @@ -0,0 +1,48 @@ +module.exports = { + execute: async ({ + models, + TeamHelper, + fragmentHelper, + collectionHelper, + notification, + userHelper, + }) => { + const eicRequestToRevision = fragmentHelper.getLatestEiCRequestToRevision() + if (!eicRequestToRevision) { + throw new Error('No Editor in Chief request to revision has been found.') + } + + let newFragment = await fragmentHelper.createFragmentFromRevision( + models.Fragment, + ) + + await fragmentHelper.removeRevision() + + const teamHelper = new TeamHelper({ + TeamModel: models.Team, + fragmentId: newFragment.id, + }) + + const authorIds = newFragment.authors.map(auth => auth.id) + + const { id: teamId } = await teamHelper.createTeam({ + role: 'author', + members: authorIds, + objectType: 'fragment', + }) + authorIds.forEach(id => { + userHelper.updateUserTeams({ userId: id, teamId }) + }) + + await collectionHelper.updateStatus({ newStatus: 'submitted' }) + + newFragment.submitted = Date.now() + newFragment = await newFragment.save() + + await collectionHelper.addFragment(newFragment.id) + + await notification.notifyEditorInChiefWhenAuthorSubmitsRevision(newFragment) + + return newFragment + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragments/strategies/heRequestRevision.js b/packages/component-manuscript-manager/src/routes/fragments/strategies/heRequestRevision.js new file mode 100644 index 0000000000000000000000000000000000000000..2a20da7ba12d003e43ddd97e7e0e814bc1ed225e --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragments/strategies/heRequestRevision.js @@ -0,0 +1,75 @@ +module.exports = { + execute: async ({ + models, + userHelper, + TeamHelper, + notification, + fragmentHelper, + collectionHelper, + }) => { + const heRequestToRevision = fragmentHelper.getLatestHERequestToRevision() + if (!heRequestToRevision) { + throw new Error('No Handling Editor request to revision has been found.') + } + + let newFragment = await fragmentHelper.createFragmentFromRevision( + models.Fragment, + ) + await fragmentHelper.removeRevision() + + const teamHelper = new TeamHelper({ + TeamModel: models.Team, + fragmentId: newFragment.id, + }) + + if (heRequestToRevision.recommendation === 'major') { + const reviewerIds = newFragment.invitations.map(inv => inv.userId) + + teamHelper.createTeam({ + role: 'reviewer', + members: reviewerIds, + objectType: 'fragment', + }) + } else { + delete newFragment.invitations + await newFragment.save() + } + + const authorIds = newFragment.authors.map(auth => auth.id) + + const { id: teamId } = await teamHelper.createTeam({ + role: 'author', + members: authorIds, + objectType: 'fragment', + }) + authorIds.forEach(id => { + userHelper.updateUserTeams({ userId: id, teamId }) + }) + + const fragments = await collectionHelper.getAllFragments({ + FragmentModel: models.Fragment, + }) + + await collectionHelper.updateStatusByRecommendation({ + recommendation: heRequestToRevision.recommendation, + fragments, + }) + + newFragment.submitted = Date.now() + newFragment = await newFragment.save() + + await collectionHelper.addFragment(newFragment.id) + + await notification.notifyHandlingEditorWhenAuthorSubmitsRevision( + newFragment, + ) + + if (heRequestToRevision.recommendation === 'major') { + await notification.notifyReviewersWhenAuthorSubmitsMajorRevision( + newFragment.id, + ) + } + + return newFragment + }, +} 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/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index 2531241656d85e7d5090e4b9256e00dcb01f0ae6..abfd90990a00b6eef6bcea5a764b4e3f028d03b3 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -1,8 +1,5 @@ -const uuid = require('uuid') -const { pick, get, set, has, isEmpty, last } = require('lodash') const config = require('config') const { v4 } = require('uuid') -const logger = require('@pubsweet/logger') const { services, @@ -11,21 +8,31 @@ const { authsome: authsomeHelper, } = require('pubsweet-component-helper-service') -const { features = {}, recommendations } = config +const { recommendations } = config +const rejectAsHE = require('./strategies/heReject') +const publishAsHE = require('./strategies/hePublish') +const rejectAsEiC = require('./strategies/eicReject') +const publishAsEiC = require('./strategies/eicPublish') +const returnToHE = require('./strategies/eicReturnToHE') const Notification = require('../../notifications/notification') +const createReview = require('./strategies/reviewerCreateReview') +const requestRevisionAsHE = require('./strategies/heRequestRevision') +const requestRevisionAsEiC = require('./strategies/eicRequestRevision') module.exports = models => async (req, res) => { - const { recommendation, comments, recommendationType } = req.body - if (!services.checkForUndefinedParams(recommendationType)) + const { recommendation, comments = [], recommendationType } = req.body + if (!services.checkForUndefinedParams(recommendationType, recommendation)) return res.status(400).json({ error: 'Recommendation type is required.' }) const reqUser = await models.User.find(req.user) + const userId = reqUser.id + const isEditorInChief = reqUser.editorInChief || reqUser.admin const { collectionId, fragmentId } = req.params - let collection, fragment, fragments + let collection, fragment try { collection = await models.Collection.find(collectionId) @@ -43,21 +50,6 @@ module.exports = models => async (req, res) => { const collectionHelper = new Collection({ collection }) - try { - fragments = await collectionHelper.getAllFragments({ - FragmentModel: models.Fragment, - }) - } catch (e) { - const notFoundError = await services.handleNotFoundError(e, 'Item') - fragments = [] - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } - const currentUserRecommendation = get(fragment, 'recommendations', []).filter( - r => r.userId === req.user, - ) - const authsome = authsomeHelper.getAuthsome(models) const target = { fragment, @@ -70,203 +62,83 @@ module.exports = models => async (req, res) => { }) const fragmentHelper = new Fragment({ fragment }) - if ( - recommendationType === recommendations.type.editor && - last(collection.fragments) !== fragmentId - ) { - return res - .status(400) - .json({ error: 'Cannot make a recommendation on an older version.' }) - } - if ( - recommendationType === recommendations.type.review && - last(collection.fragments) !== fragmentId - ) { - return res - .status(400) - .json({ error: 'Cannot write a review on an older version.' }) - } - if ( - last(collection.fragments) === fragmentId && - !isEmpty(currentUserRecommendation) - ) { - if (recommendationType === recommendations.type.review) { - return res - .status(400) - .json({ error: 'Cannot write another review on this version.' }) - } - return res - .status(400) - .json({ error: 'Cannot make another recommendation on this version.' }) + if (!collectionHelper.isLatestVersion(fragmentId)) { + const error = + recommendationType === recommendations.type.editor + ? 'Cannot make a recommendation on an older version.' + : 'Cannot write a review on an older version.' + return res.status(400).json({ error }) } - if ( - recommendation === recommendations.publish && - recommendationType === recommendations.type.editor && - collection.handlingEditor && - collection.handlingEditor.id === req.user - ) { - if (!collectionHelper.canHEMakeRecommendation(fragments, fragmentHelper)) { - return res.status(400).json({ - error: 'Cannot publish without at least one reviewer report.', - }) - } - } - - fragment.recommendations = fragment.recommendations || [] const newRecommendation = { - id: uuid.v4(), - userId: reqUser.id, + userId, + id: v4(), + comments, + recommendation, + recommendationType, createdOn: Date.now(), updatedOn: Date.now(), - recommendationType, } - newRecommendation.recommendation = recommendation || undefined - newRecommendation.comments = comments || undefined - - if (recommendationType === 'editorRecommendation') { - await collectionHelper.updateStatusOnRecommendation({ - isEditorInChief, - recommendation, - }) - - if (!isEditorInChief && ['minor', 'major'].includes(recommendation)) { - fragment.revision = pick(fragment, ['authors', 'files', 'metadata']) - } - - const technicalChecks = get(collection, 'technicalChecks', {}) - const hasEQA = has(technicalChecks, 'eqa') - // the manuscript has not yet passed through the EQA process so we need to upload it to the FTP server - if (isEditorInChief && recommendation === 'publish' && !hasEQA) { - if (features.mts) { - await Promise.all( - collection.fragments.map(async fragmentId => { - const fragment = await models.Fragment.find(fragmentId) - const fragmentHelper = new Fragment({ fragment }) + const notification = new Notification({ + fragment, + collection, + newRecommendation, + UserModel: models.User, + baseUrl: services.getBaseUrl(req), + }) - let fragmentUsers = [] - try { - fragmentUsers = await fragmentHelper.getReviewersAndEditorsData({ - collection, - UserModel: models.User, - }) + const strategies = { + he: { + reject: rejectAsHE, + publish: publishAsHE, + major: requestRevisionAsHE, + minor: requestRevisionAsHE, + }, + eic: { + reject: rejectAsEiC, + publish: publishAsEiC, + revision: requestRevisionAsEiC, + 'return-to-handling-editor': returnToHE, + }, + } - await sendMTSPackage({ - collection, - fragment, - isEQA: true, - fragmentUsers, - }) - } catch (e) { - logger.error(e) - } - }), - ).catch(e => - res.status(500).json({ - error: 'Something went wrong.', - }), - ) + let role = '' + switch (recommendationType) { + case 'review': + role = 'reviewer' + try { + await createReview.execute({ + userId, + fragmentHelper, + newRecommendation, + }) + } catch (e) { + return res.status(400).json({ error: e.message }) } + return res.status(200).json(newRecommendation) + case 'editorRecommendation': + role = isEditorInChief ? 'eic' : 'he' + break + default: + return res.status(400).json({ + error: `Recommendation ${recommendation} is not defined.`, + }) + } - collection.status = 'inQA' - set(collection, 'technicalChecks.token', v4()) - set(collection, 'technicalChecks.eqa', false) - await collection.save() - } - - /* if the EiC returns the manuscript to the HE after the EQA has been performed - then remove all properties from the technicalChecks property so that the manuscript - can go through the EQA process again - */ - if ( - isEditorInChief && - recommendation === 'return-to-handling-editor' && - hasEQA - ) { - collection.technicalChecks = {} - await collection.save() - } - - const notification = new Notification({ - fragment, - collection, + try { + await strategies[role][recommendation].execute({ + userId, + models, + notification, + fragmentHelper, + collectionHelper, newRecommendation, - UserModel: models.User, - baseUrl: services.getBaseUrl(req), }) - - const hasPeerReview = !isEmpty(collection.handlingEditor) - - if (isEditorInChief) { - if (recommendation === 'publish' && collection.status === 'inQA') { - notification.notifyEAWhenEiCRequestsEQAApproval() - } - - if (recommendation === 'publish' && collection.status === 'accepted') { - notification.notifyEAWhenEiCMakesFinalDecision() - } - - if (hasPeerReview && (recommendation !== 'publish' || hasEQA)) { - if (recommendation === 'return-to-handling-editor') { - notification.notifyHEWhenEiCReturnsToHE() - } else { - notification.notifyHEWhenEiCMakesDecision() - notification.notifyReviewersWhenEiCMakesDecision() - } - } - - if ( - recommendation !== 'return-to-handling-editor' && - (recommendation !== 'publish' || hasEQA) - ) { - notification.notifyAuthorsWhenEiCMakesDecision() - } - } else { - if (collection.status === 'revisionRequested') { - notification.notifySAWhenHERequestsRevision() - } - - if (hasPeerReview) { - notification.notifyReviewersWhenHEMakesRecommendation() - notification.notifyEiCWhenHEMakesRecommendation() - } - } + } catch (e) { + return res.status(400).json({ error: e.message }) } - fragment.recommendations.push(newRecommendation) - fragment.save() - return res.status(200).json(newRecommendation) } - -const sendMTSPackage = async ({ - fragment, - collection, - isEQA = false, - fragmentUsers = [], -}) => { - const s3Config = get(config, 'pubsweet-component-aws-s3', {}) - const mtsConfig = get(config, 'mts-service', {}) - const { sendPackage } = require('pubsweet-component-mts-package') - - const { journal, xmlParser, ftp } = mtsConfig - const packageFragment = { - ...fragment, - metadata: { - ...fragment.metadata, - customId: collection.customId, - }, - } - - await sendPackage({ - isEQA, - s3Config, - fragmentUsers, - ftpConfig: ftp, - config: journal, - options: xmlParser, - fragment: packageFragment, - }) -} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicPublish.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicPublish.js new file mode 100644 index 0000000000000000000000000000000000000000..6915c54324dbaa41b6d1aea25c4634c333940d5a --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicPublish.js @@ -0,0 +1,45 @@ +const config = require('config') + +const { features = {} } = config + +module.exports = { + execute: async ({ + models, + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + const latestRecommendation = fragmentHelper.getLatestRecommendation() + if (latestRecommendation.recommendation === 'return-to-handling-editor') { + throw new Error( + 'Cannot make decision to publish after the manuscript has been returned to Handling Editor.', + ) + } + + await fragmentHelper.addRecommendation(newRecommendation) + + let newStatus = '' + if (collectionHelper.hasEQA()) { + newStatus = 'accepted' + notification.notifyEAWhenEiCMakesFinalDecision() + notification.notifyAuthorsWhenEiCMakesDecision() + notification.notifyHEWhenEiCMakesDecision() + notification.notifyReviewersWhenEiCMakesDecision() + } else { + if (features.mts) { + await collectionHelper.sendToMTS({ + fragmentHelper, + UserModel: models.User, + FragmentModel: models.Fragment, + }) + } + + newStatus = 'inQA' + await collectionHelper.setTechnicalChecks() + notification.notifyEAWhenEiCRequestsEQAApproval() + } + + await collectionHelper.updateStatus({ newStatus }) + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReject.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReject.js new file mode 100644 index 0000000000000000000000000000000000000000..b10ff0e2a46b93c6d56e85ab0d30f2e345d7f1dd --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReject.js @@ -0,0 +1,19 @@ +module.exports = { + execute: async ({ + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + await fragmentHelper.addRecommendation(newRecommendation) + await collectionHelper.updateStatus({ newStatus: 'rejected' }) + + notification.notifyAuthorsWhenEiCMakesDecision() + if (collectionHelper.hasHandlingEditor()) { + notification.notifyHEWhenEiCMakesDecision() + } + if (fragmentHelper.hasReviewers()) { + notification.notifyReviewersWhenEiCMakesDecision() + } + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicRequestRevision.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicRequestRevision.js new file mode 100644 index 0000000000000000000000000000000000000000..0f66d8af7163765974379ceee97f12b887cbae5b --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicRequestRevision.js @@ -0,0 +1,19 @@ +module.exports = { + execute: async ({ + fragmentHelper, + collectionHelper, + newRecommendation, + notification, + }) => { + if (collectionHelper.hasHandlingEditor()) { + throw new Error( + 'Cannot make request a revision after a Handling Editor has been assigned.', + ) + } + + await fragmentHelper.addRevision() + await collectionHelper.updateStatus({ newStatus: 'revisionRequested' }) + await fragmentHelper.addRecommendation(newRecommendation) + await notification.notifySAWhenEiCRequestsRevision() + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReturnToHE.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReturnToHE.js new file mode 100644 index 0000000000000000000000000000000000000000..633d9c1b8c61065ed65841f9c43c9c250cfa7424 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/eicReturnToHE.js @@ -0,0 +1,22 @@ +module.exports = { + execute: async ({ + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + const latestRecommendation = fragmentHelper.getLatestRecommendation() + if (latestRecommendation.recommendation === 'return-to-handling-editor') { + throw new Error('Cannot return to Handling Editor again.') + } + + if (collectionHelper.hasEQA()) { + await collectionHelper.removeTechnicalChecks() + } + await collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) + + await fragmentHelper.addRecommendation(newRecommendation) + + notification.notifyHEWhenEiCReturnsToHE() + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/hePublish.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/hePublish.js new file mode 100644 index 0000000000000000000000000000000000000000..238efbc141b40fda46e5c048afb3e9031a8213fb --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/hePublish.js @@ -0,0 +1,31 @@ +module.exports = { + execute: async ({ + userId, + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + const fragments = await collectionHelper.collection.getFragments() + + if (!collectionHelper.canHEMakeRecommendation(fragments, fragmentHelper)) { + throw new Error('Cannot publish without at least one reviewer report.') + } + + const latestUserRecommendation = fragmentHelper.getLatestUserRecommendation( + userId, + ) + if ( + latestUserRecommendation && + !fragmentHelper.canHEMakeAnotherRecommendation(latestUserRecommendation) + ) { + throw new Error('Cannot make another recommendation on this version.') + } + + await fragmentHelper.addRecommendation(newRecommendation) + await collectionHelper.updateStatus({ newStatus: 'pendingApproval' }) + + notification.notifyReviewersWhenHEMakesRecommendation() + notification.notifyEiCWhenHEMakesRecommendation() + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heReject.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heReject.js new file mode 100644 index 0000000000000000000000000000000000000000..cd76a8beba735d8ef668dec8c3baef86ebbcaa41 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heReject.js @@ -0,0 +1,27 @@ +module.exports = { + execute: async ({ + userId, + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + const latestUserRecommendation = fragmentHelper.getLatestUserRecommendation( + userId, + ) + if ( + latestUserRecommendation && + !fragmentHelper.canHEMakeAnotherRecommendation(latestUserRecommendation) + ) { + throw new Error('Cannot make another recommendation on this version.') + } + + await fragmentHelper.addRecommendation(newRecommendation) + await collectionHelper.updateStatus({ newStatus: 'pendingApproval' }) + + if (fragmentHelper.hasReviewers()) { + notification.notifyReviewersWhenHEMakesRecommendation() + } + notification.notifyEiCWhenHEMakesRecommendation() + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heRequestRevision.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heRequestRevision.js new file mode 100644 index 0000000000000000000000000000000000000000..7daeb25d9e400a02de63c8693d1c99a48e02e0c5 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/heRequestRevision.js @@ -0,0 +1,30 @@ +module.exports = { + execute: async ({ + userId, + notification, + fragmentHelper, + collectionHelper, + newRecommendation, + }) => { + const latestUserRecommendation = fragmentHelper.getLatestUserRecommendation( + userId, + ) + if ( + latestUserRecommendation && + !fragmentHelper.canHEMakeAnotherRecommendation(latestUserRecommendation) + ) { + throw new Error('Cannot make another recommendation on this version.') + } + + await fragmentHelper.addRevision() + await collectionHelper.updateStatus({ newStatus: 'revisionRequested' }) + await fragmentHelper.addRecommendation(newRecommendation) + + notification.notifySAWhenHERequestsRevision() + notification.notifyEiCWhenHEMakesRecommendation() + + if (fragmentHelper.hasReviewers()) { + notification.notifyReviewersWhenHEMakesRecommendation() + } + }, +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/reviewerCreateReview.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/reviewerCreateReview.js new file mode 100644 index 0000000000000000000000000000000000000000..9b8f450e33a9597a1b25b59f964392cc2befe3b9 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/strategies/reviewerCreateReview.js @@ -0,0 +1,9 @@ +module.exports = { + execute: async ({ fragmentHelper, newRecommendation, userId }) => { + if (fragmentHelper.getLatestUserRecommendation(userId)) { + throw new Error('Cannot write another review on this version.') + } + + await fragmentHelper.addRecommendation(newRecommendation) + }, +} 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 fb88e5658444b5c3bad2f2cf5ac7760bf9900537..7ad0c1284762c4173c9b7e948fb2d273233eb166 100644 --- a/packages/component-manuscript-manager/src/tests/collections/get.test.js +++ b/packages/component-manuscript-manager/src/tests/collections/get.test.js @@ -36,7 +36,7 @@ describe('Get collections route handler', () => { expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) - expect(data).toHaveLength(1) + expect(data).toHaveLength(handlingEditor.teams.length) expect(data[0].type).toEqual('collection') expect(data[0]).toHaveProperty('currentVersion') expect(data[0]).toHaveProperty('visibleStatus') @@ -58,10 +58,8 @@ 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(3) expect(data[0].currentVersion.authors[0]).not.toHaveProperty('email') }) 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..b6cb60fb809b12c4b399ef3b38f912ecd7c99974 100644 --- a/packages/component-manuscript-manager/src/tests/fragments/patch.test.js +++ b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js @@ -83,7 +83,7 @@ describe('Patch fragments route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Item not found') }) - it('should return an error when no HE recommendation exists', async () => { + it('should return an error when no HE request to revision exists', async () => { const { user } = testFixtures.users const { fragment } = testFixtures.fragments const { collection } = testFixtures.collections @@ -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 @@ -169,4 +171,53 @@ describe('Patch fragments route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Unauthorized.') }) + it('should return an error when no EiC request to revision exists', async () => { + const { user } = testFixtures.users + const { fragment } = testFixtures.fragments + const { collection } = testFixtures.collections + fragment.recommendations.length = 0 + delete collection.handlingEditor + + const res = await requests.sendRequest({ + body, + userId: user.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( + 'No Editor in Chief request to revision has been found.', + ) + }) + it('should return success when an EiC request to revision exists', async () => { + const { user } = testFixtures.users + const { fragment } = testFixtures.fragments + const { collection } = testFixtures.collections + + delete collection.handlingEditor + + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data).toHaveProperty('submitted') + expect(collection.status).toBe('submitted') + }) }) 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 df52effe144daf17b1a9ec16e546c4f189b62187..409e5238ef0062715a53031633e33413f27dcbf5 100644 --- a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js +++ b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js @@ -63,6 +63,7 @@ describe('Post fragments recommendations route handler', () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections const { fragment } = testFixtures.fragments + body.recommendationType = 'review' const res = await requests.sendRequest({ body, @@ -102,7 +103,7 @@ describe('Post fragments recommendations route handler', () => { 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 () => { + it('should return an error when recommending to publish as 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 @@ -129,52 +130,45 @@ describe('Post fragments recommendations route handler', () => { }) 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 { handlingEditor: { id: userId } } = testFixtures.users const { - minorRevisionWithReview, - noInvitesFragment1, - } = testFixtures.fragments + minorRevisionCollection: { id: collectionId }, + } = testFixtures.collections + const { noInvitesFragment1: { id: fragmentId } } = testFixtures.fragments - collection.fragments = [minorRevisionWithReview.id, noInvitesFragment1.id] const res = await requests.sendRequest({ body, - userId: handlingEditor.id, + userId, models, route, path, params: { - collectionId: collection.id, - fragmentId: noInvitesFragment1.id, + collectionId, + fragmentId, }, }) expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) - expect(data.userId).toEqual(handlingEditor.id) + expect(data.userId).toEqual(userId) }) 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 { handlingEditor: { id: userId } } = testFixtures.users const { - minorRevisionWithoutReview, - noInvitesFragment1, - } = testFixtures.fragments - - collection.fragments = [ - minorRevisionWithoutReview.id, - noInvitesFragment1.id, - ] + minorRevisionWithoutReviewCollection: { id: collectionId }, + } = testFixtures.collections + const { noInvitesFragment1: { id: fragmentId } } = testFixtures.fragments + const res = await requests.sendRequest({ body, - userId: handlingEditor.id, + userId, models, route, path, params: { - collectionId: collection.id, - fragmentId: noInvitesFragment1.id, + collectionId, + fragmentId, }, }) @@ -186,53 +180,46 @@ describe('Post fragments recommendations route handler', () => { }) 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 { handlingEditor: { id: userId } } = testFixtures.users const { - majorRevisionWithReview, - reviewCompletedFragment, - } = testFixtures.fragments - - reviewCompletedFragment.collectionId = collection.id - collection.fragments = [ - majorRevisionWithReview.id, - reviewCompletedFragment.id, - ] + majorRevisionCollection: { id: collectionId }, + } = testFixtures.collections + const { reviewCompletedFragment } = testFixtures.fragments + + reviewCompletedFragment.collectionId = collectionId const res = await requests.sendRequest({ body, - userId: handlingEditor.id, + userId, models, route, path, params: { - collectionId: collection.id, + collectionId, fragmentId: reviewCompletedFragment.id, }, }) expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) - expect(data.userId).toEqual(handlingEditor.id) + expect(data.userId).toEqual(userId) }) 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 { handlingEditor: { id: userId } } = testFixtures.users const { - majorRevisionWithReview, - noInvitesFragment1, - } = testFixtures.fragments + majorRevisionWithoutReviewCollection: { id: collectionId }, + } = testFixtures.collections + const { noInvitesFragment1: { id: fragmentId } } = testFixtures.fragments - collection.fragments = [majorRevisionWithReview.id, noInvitesFragment1.id] const res = await requests.sendRequest({ body, - userId: handlingEditor.id, + userId, models, route, path, params: { - collectionId: collection.id, - fragmentId: noInvitesFragment1.id, + collectionId, + fragmentId, }, }) @@ -445,9 +432,6 @@ describe('Post fragments recommendations route handler', () => { body.recommendationType = 'editorRecommendation' body.comments = 'This needs more work' - delete fragment.recommendations - delete fragment.revision - delete fragment.invitations collection.technicalChecks.eqa = false const res = await requests.sendRequest({ @@ -464,7 +448,6 @@ describe('Post fragments recommendations route handler', () => { expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) - expect(collection.status).toBe('reviewCompleted') expect(collection.technicalChecks).not.toHaveProperty('token') expect(collection.technicalChecks).not.toHaveProperty('eqa') @@ -571,6 +554,52 @@ describe('Post fragments recommendations route handler', () => { expect(data.error).toEqual('Cannot write another review on this version.') }) + it('should return success when creating another recommendation as a HE on the same version when EiC returned manuscript to He ', async () => { + const { noRecommendationHE } = testFixtures.users + const { noEditorRecomedationCollection } = testFixtures.collections + const { noEditorRecomedationFragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: noRecommendationHE.id, + models, + route, + path, + params: { + collectionId: noEditorRecomedationCollection.id, + fragmentId: noEditorRecomedationFragment.id, + }, + }) + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.userId).toEqual(noRecommendationHE.id) + }) + + it('should return an error when creating another recommendation as a HE on the same version after EiC made decision to publish', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'publish' + body.recommendationType = 'editorRecommendation' + + 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 make another recommendation on this version.', + ) + }) + it('should return an error when an EiC makes a decision on an older version of a manuscript', async () => { const { editorInChief } = testFixtures.users const { twoVersionsCollection } = testFixtures.collections @@ -597,4 +626,55 @@ describe('Post fragments recommendations route handler', () => { 'Cannot make a recommendation on an older version.', ) }) + it('should return success when an EiC requests a revision before the Handling Editor is assigned', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'revision' + body.recommendationType = 'editorRecommendation' + delete collection.handlingEditor + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.userId).toEqual(editorInChief.id) + expect(collection.status).toEqual('revisionRequested') + expect(fragment).toHaveProperty('revision') + }) + it('should return an error when an EiC requests a revision after a Handling Editor is assigned', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'revision' + body.recommendationType = 'editorRecommendation' + + const res = await requests.sendRequest({ + body, + userId: editorInChief.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 make request a revision after a Handling Editor has been assigned.', + ) + }) }) diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index 4953fa278e884980a5bfe28168c62a6a0e97a455..589bf76083c0852480e0d3bde0e3a62ed13bfbf9 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -17,13 +17,14 @@ 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', reject: 'Comments for Author', + revision: 'Comments for Author', } const cannotViewReviewersDetails = ['revisionRequested', 'pendingApproval'] @@ -57,6 +58,7 @@ const ManuscriptLayout = ({ heResponseExpanded, inviteHandlingEditor, toggleReviewerDetails, + isFetchingFromAutosave, recommendationHandler, toggleReviewerResponse, reviewerDetailsExpanded, @@ -122,17 +124,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')} /> )} @@ -154,6 +164,7 @@ const ManuscriptLayout = ({ changeForm={changeForm} expanded={reviewerRecommendationExpanded} formValues={get(formValues, 'reviewerReport', {})} + isFetchingFromAutosave={isFetchingFromAutosave} modalKey="reviewer-report" project={collection} review={pendingOwnRecommendation} @@ -163,17 +174,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 +197,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..928d9077cc3d789097545716b34e5431ce9d0b14 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -46,6 +46,7 @@ import { pendingHEInvitation, currentUserIsReviewer, parseCollectionDetails, + isFetchingFromAutosave, canMakeHERecommendation, canViewReviewersDetails, canViewEditorialComments, @@ -57,6 +58,7 @@ import { getOwnPendingRecommendation, getOwnSubmittedRecommendation, canAuthorViewEditorialComments, + reviewersCanViewReviewerReports, canHEMakeRecommendationToPublish, getFragmentReviewerRecommendations, getInvitationsWithReviewersForFragment, @@ -177,7 +179,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 +201,11 @@ export default compose( collection, get(fragment, 'id', ''), ), + reviewersCanViewReviewerReports: reviewersCanViewReviewerReports( + state, + collection, + get(fragment, 'id', ''), + ), canOverrideTechChecks: canOverrideTechnicalChecks(state, collection), canAuthorViewEditorialComments: canAuthorViewEditorialComments( state, @@ -217,6 +228,7 @@ export default compose( editorsFetching: selectFetching(state), publonsFetching: isFetching, }, + isFetchingFromAutosave: isFetchingFromAutosave(state), formValues: { revision: getFormValues('revision')(state), eicDecision: getFormValues('eic-decision')(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/components/SubmitRevision.js b/packages/component-manuscript/src/components/SubmitRevision.js index 0fa81380877fdf5ff9c2be286c62971c01139fe8..ae6e333fd324b1d97cb7185afec66b71b14d54fc 100644 --- a/packages/component-manuscript/src/components/SubmitRevision.js +++ b/packages/component-manuscript/src/components/SubmitRevision.js @@ -106,7 +106,7 @@ const SubmitRevision = ({ </CustomValidatedField> </Expandable> {!isEmpty(reviews) && ( - <Expandable label="RESPONSE TO REVIEWER COMMENTS" startExpanded> + <Expandable label="RESPONSE TO REVISION REQUEST" startExpanded> <Title>Reply text*</Title> <Row> <FullWidth className="full-width"> 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/component-mts-package/src/MTS.js b/packages/component-mts-package/src/MTS.js index 3f9e503df78bbf5d3273f0e7e19dd4d3c764b1e1..4b9cdadfcd4ce88fcebde24378b9b5ab6c527a7d 100644 --- a/packages/component-mts-package/src/MTS.js +++ b/packages/component-mts-package/src/MTS.js @@ -38,17 +38,21 @@ module.exports = { fragment, xmlFile, isEQA, - }).then(() => { - const packageName = get(xmlFile, 'name', '').replace('.xml', '') - const filename = isEQA - ? `ACCEPTED_${packageName}.${fragment.version}.zip` - : `${packageName}.zip` + }) + .then(() => { + const packageName = get(xmlFile, 'name', '').replace('.xml', '') + const filename = isEQA + ? `ACCEPTED_${packageName}.${fragment.version}.zip` + : `${packageName}.zip` - return PackageManager.uploadFiles({ - filename, - s3Config, - config: ftpConfig, + return PackageManager.uploadFiles({ + filename, + s3Config, + config: ftpConfig, + }) + }) + .catch(e => { + throw new Error(e) }) - }) }, } diff --git a/packages/component-user-manager/src/routes/users/changePassword.js b/packages/component-user-manager/src/routes/users/changePassword.js index 30073accccd603baa60cdea466dc3a35ded680f1..7630d4374bdd826352a8db87a41cd1fb4b3d5761 100644 --- a/packages/component-user-manager/src/routes/users/changePassword.js +++ b/packages/component-user-manager/src/routes/users/changePassword.js @@ -1,24 +1,24 @@ const { services } = require('pubsweet-component-helper-service') const { token } = require('pubsweet-server/src/authentication') +const { passwordStrengthRegex } = require('config') module.exports = models => async (req, res) => { - const { password, newPassword } = req.body - if (!services.checkForUndefinedParams(password, newPassword)) + const { currentPassword, password } = req.body + if (!services.checkForUndefinedParams(currentPassword, password)) return res.status(400).json({ error: 'Missing required params.' }) - - if (newPassword.length < 7) - return res - .status(400) - .json({ error: 'Password needs to be at least 7 characters long.' }) + if (!passwordStrengthRegex.test(password)) + return res.status(400).json({ + error: 'Password is too weak. Please check password requirements.', + }) let user try { user = await models.User.find(req.user) - if (!await user.validPassword(password)) { + if (!await user.validPassword(currentPassword)) { return res.status(400).json({ error: 'Wrong username or password.' }) } - user.password = newPassword + user.password = password user = await user.save() return res.status(200).json({ diff --git a/packages/component-user-manager/src/routes/users/post.js b/packages/component-user-manager/src/routes/users/post.js index 8150e2ca77f46131c69325b591e5a58dcd93ad1c..44595c0ae46cb5549f0b95f1c0e315df3666f3e2 100644 --- a/packages/component-user-manager/src/routes/users/post.js +++ b/packages/component-user-manager/src/routes/users/post.js @@ -1,5 +1,6 @@ const { pick } = require('lodash') const Chance = require('chance') +const { passwordStrengthRegex } = require('config') const chance = new Chance() @@ -15,6 +16,10 @@ module.exports = models => async (req, res) => { error: 'Terms & Conditions must be read and approved.', }) } + if (!passwordStrengthRegex.test(req.body.password)) + return res.status(400).json({ + error: 'Password is too weak. Please check password requirements.', + }) req.body = pick(req.body, [ 'email', 'title', diff --git a/packages/component-user-manager/src/routes/users/resetPassword.js b/packages/component-user-manager/src/routes/users/resetPassword.js index b46c9cafdccf18ddab7facd6e629cc4a0f92a065..c42dd4cc1037f15b22acb0f0899b1cf97e420eae 100644 --- a/packages/component-user-manager/src/routes/users/resetPassword.js +++ b/packages/component-user-manager/src/routes/users/resetPassword.js @@ -1,14 +1,16 @@ const { services } = require('pubsweet-component-helper-service') +const { passwordStrengthRegex } = require('config') + module.exports = models => async (req, res) => { const { email, password, token } = req.body if (!services.checkForUndefinedParams(email, password, token)) return res.status(400).json({ error: 'missing required params' }) - if (password.length < 7) - return res - .status(400) - .json({ error: 'password needs to be at least 7 characters long' }) + if (!passwordStrengthRegex.test(req.body.password)) + return res.status(400).json({ + error: 'Password is too weak. Please check password requirements.', + }) const validateResponse = await services.validateEmailAndToken({ email, diff --git a/packages/component-user-manager/src/tests/users/changePassword.test.js b/packages/component-user-manager/src/tests/users/changePassword.test.js index a97b1e0fe30e1c5605f7a30167d0034c9fd8e9d9..c4118fb54422b3d17377400da3401df9ddd2a90a 100644 --- a/packages/component-user-manager/src/tests/users/changePassword.test.js +++ b/packages/component-user-manager/src/tests/users/changePassword.test.js @@ -13,8 +13,8 @@ jest.mock('@pubsweet/component-send-email', () => ({ })) const reqBody = { - password: 'password', - newPassword: 'newPassword', + currentPassword: 'password', + password: 'N3wPassword!', } const notFoundError = new Error() @@ -44,7 +44,7 @@ describe('Users password reset route handler', () => { expect(data.error).toEqual('Missing required params.') }) it('should return an error when the password is too small', async () => { - body.newPassword = 'small' + body.password = 'small' const req = httpMocks.createRequest({ body }) req.user = user.id @@ -53,7 +53,7 @@ describe('Users password reset route handler', () => { expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual( - 'Password needs to be at least 7 characters long.', + 'Password is too weak. Please check password requirements.', ) }) it('should return an error when user is not found', async () => { @@ -67,7 +67,7 @@ describe('Users password reset route handler', () => { expect(data.error).toEqual('User not found') }) it('should return an error when the current password is incorrect', async () => { - body.password = 'invalid-password' + body.currentPassword = 'invalid-password' const req = httpMocks.createRequest({ body }) req.user = user.id @@ -90,6 +90,6 @@ describe('Users password reset route handler', () => { savedUser.validPassword = user.validPassword expect(savedUser.token).not.toEqual(user.token) - expect(savedUser.validPassword(body.newPassword)).toBeTruthy() + expect(savedUser.validPassword(body.password)).toBeTruthy() }) }) diff --git a/packages/component-user-manager/src/tests/users/resetPassword.test.js b/packages/component-user-manager/src/tests/users/resetPassword.test.js index 6b9dfc7e9fcae7d466155e1ad227cb8fce07da07..a802f929d1704c907cfaeaec3d546720e324d1d4 100644 --- a/packages/component-user-manager/src/tests/users/resetPassword.test.js +++ b/packages/component-user-manager/src/tests/users/resetPassword.test.js @@ -20,7 +20,7 @@ const reqBody = { lastName: user.lastName, title: user.title, affiliation: user.affiliation, - password: 'password', + password: 'P4ssword!', token: user.accessTokens.passwordReset, isConfirmed: false, } @@ -57,7 +57,7 @@ describe('Users password reset route handler', () => { expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual( - 'password needs to be at least 7 characters long', + 'Password is too weak. Please check password requirements.', ) }) it('should return an error when user is not found', async () => { diff --git a/packages/components-faraday/src/components/Login/LoginPage.js b/packages/components-faraday/src/components/Login/LoginPage.js index 55893a41090cf2a321853163a28edad365e6e8c1..329c6038d6814236ae137b8fe15e2e0fbde48cc5 100644 --- a/packages/components-faraday/src/components/Login/LoginPage.js +++ b/packages/components-faraday/src/components/Login/LoginPage.js @@ -14,11 +14,12 @@ import { Text, Label, ActionLink, + withFetching, } from 'pubsweet-component-faraday-ui' const PasswordField = input => <TextField {...input} type="password" /> -const Login = ({ handleSubmit, loginError }) => ( +const Login = ({ handleSubmit, fetchingError }) => ( <Root onSubmit={handleSubmit}> <CustomH2>Login</CustomH2> <Row mt={3}> @@ -53,9 +54,9 @@ const Login = ({ handleSubmit, loginError }) => ( LOG IN </Button> - {loginError && ( + {fetchingError && ( <Row justify="flex-start" mt={1}> - <Text error>{loginError}</Text> + <Text error>{fetchingError}</Text> </Row> )} @@ -71,14 +72,10 @@ const Login = ({ handleSubmit, loginError }) => ( ) const LoginPage = compose( - connect( - state => ({ - loginError: state.error, - }), - { - logoutUser, - }, - ), + withFetching, + connect(null, { + logoutUser, + }), withProps({ passwordReset: true }), lifecycle({ componentDidMount() { @@ -89,9 +86,9 @@ const LoginPage = compose( reduxForm({ form: 'login', enableReinitialize: false, - onSubmit: (values, dispatch, { location }) => { + onSubmit: (values, dispatch, { location, setError }) => { const redirectTo = get(location, 'state.from.pathname', '/dashboard') - dispatch(loginUser(values, redirectTo)) + dispatch(loginUser(values, redirectTo, setError)) }, }), )(Login) diff --git a/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js b/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js index 917b0073bed22be7d1955dc933f35dc949ca9c03..90a234ae4ed34d1a692bf295cebf7ac4ae431cfc 100644 --- a/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js +++ b/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js @@ -1,29 +1,25 @@ import React from 'react' import { connect } from 'react-redux' import { reduxForm } from 'redux-form' -import { required, minChars } from 'xpub-validators' +import { Button, H2, Spinner } from '@pubsweet/ui' import { compose, withState, lifecycle } from 'recompose' import { loginUser } from 'pubsweet-component-login/actions' -import { Button, ValidatedField, H2, TextField, Spinner } from '@pubsweet/ui' import { Row, - Item, Text, - Label, ShadowedBox, + PasswordValidation, handleError, withFetching, + passwordValidator, } from 'pubsweet-component-faraday-ui' -import { redirectToError, passwordValidator } from '../utils' +import { redirectToError } from '../utils' import { reviewerDecision, setReviewerPassword } from '../../redux/reviewers' const agreeText = `You have been invited to review a manuscript on the Hindawi platform. Please set a password and proceed to the manuscript.` const declineText = `You have decline to work on a manuscript.` -const PasswordField = input => <TextField {...input} type="password" /> -const min8Chars = minChars(8) - const ReviewerInviteDecision = ({ agree, error, @@ -43,27 +39,7 @@ const ReviewerInviteDecision = ({ <Text align="center">{agree === 'true' ? agreeText : declineText}</Text> </Row> - <Row mt={2}> - <Item vertical> - <Label required>Password</Label> - <ValidatedField - component={PasswordField} - name="password" - validate={[required, min8Chars]} - /> - </Item> - </Row> - - <Row mt={2}> - <Item vertical> - <Label required>Confirm password</Label> - <ValidatedField - component={PasswordField} - name="confirmPassword" - validate={[required]} - /> - </Item> - </Row> + <PasswordValidation formLabel="Password" formName="invite-reviewer" /> {fetchingError && ( <Row mt={2}> diff --git a/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js index b4f9d3f014d0c61fa7cf83f048044a5b5cf9121a..bc87c22263ce048c3d4d0bd7c4381221b8d2f609 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js +++ b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js @@ -5,6 +5,13 @@ import { Text, ShadowedBox } from 'pubsweet-component-faraday-ui' import Step0 from './SignUpStep0' import Step1 from './SignUpStep1' +const containerPadding = { + pt: 4, + pb: 4, + pl: 4, + pr: 4, +} + const SignUpInvitation = ({ type, step, @@ -18,8 +25,8 @@ const SignUpInvitation = ({ initialValues, title = 'Add New Account Details', }) => ( - <ShadowedBox center mb={3} mt={10}> - <H2>{title}</H2> + <ShadowedBox center mb={3} mt={10} {...containerPadding}> + <H2 mb={step === 0 ? 0 : 2}>{title}</H2> {error && <Text error>Token expired or Something went wrong.</Text>} {step === 0 && ( <Step0 diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep0.js b/packages/components-faraday/src/components/SignUp/SignUpStep0.js index 4cb4f094d06d1585a6a814e4cf1fda0af3b3d9bf..dc60d273a5a3ea675d019a15be37db17a0016c85 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpStep0.js +++ b/packages/components-faraday/src/components/SignUp/SignUpStep0.js @@ -11,8 +11,8 @@ import { Item, Label, ActionLink, + MenuCountry, ItemOverrideAlert, - withCountries, } from 'pubsweet-component-faraday-ui' const AgreeCheckbox = ({ value, onChange }) => ( @@ -21,20 +21,13 @@ const AgreeCheckbox = ({ value, onChange }) => ( <Text> I agree with the{' '} <ActionLink to="https://www.hindawi.com/terms/"> - Terms of Services + Terms of Service </ActionLink>{' '} </Text> </Row> ) -const Step0 = ({ - type, - error, - journal, - countries, - handleSubmit, - initialValues, -}) => +const Step0 = ({ type, error, journal, handleSubmit, initialValues }) => !isUndefined(initialValues) ? ( <Fragment> <Row mb={2} mt={3}> @@ -75,11 +68,7 @@ const Step0 = ({ <Label required>Country</Label> <ValidatedField component={input => ( - <Menu - {...input} - options={countries} - placeholder="Please select" - /> + <MenuCountry {...input} placeholder="Please select" /> )} name="country" validate={[requiredValidator]} @@ -142,7 +131,6 @@ const Step0 = ({ ) export default compose( - withCountries, reduxForm({ form: 'signUpInvitation', destroyOnUnmount: false, diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep1.js b/packages/components-faraday/src/components/SignUp/SignUpStep1.js index 21177e76eb9fa5006d88c02b432cf0057e3fa2a5..207ee958c0fe78e568b8b3ef3e64155940f618c2 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpStep1.js +++ b/packages/components-faraday/src/components/SignUp/SignUpStep1.js @@ -2,11 +2,17 @@ import React, { Fragment } from 'react' import { reduxForm } from 'redux-form' import { required } from 'xpub-validators' import { Button, ValidatedField, TextField } from '@pubsweet/ui' -import { Row, Item, Label, Text } from 'pubsweet-component-faraday-ui' +import { + Row, + Item, + Label, + Text, + PasswordValidation, + passwordValidator, +} from 'pubsweet-component-faraday-ui' -import { passwordValidator, emailValidator } from '../utils' +import { emailValidator } from '../utils' -const PasswordField = input => <TextField {...input} type="password" /> const EmailField = input => <TextField {...input} type="email" /> const SignUpForm = () => ( @@ -21,52 +27,12 @@ const SignUpForm = () => ( /> </Item> </Row> - <Row mb={2}> - <Item data-test-id="sign-up-password" vertical> - <Label required>Password</Label> - <ValidatedField - component={PasswordField} - name="password" - validate={[required]} - /> - </Item> - </Row> - <Row mb={2}> - <Item data-test-id="sign-up-confirm-password" vertical> - <Label required>Confirm password</Label> - <ValidatedField - component={PasswordField} - name="confirmPassword" - validate={[required]} - /> - </Item> - </Row> + <PasswordValidation formLabel="Password" formName="signUpInvitation" /> </Fragment> ) const InviteForm = () => ( - <Fragment> - <Row mb={2} mt={2}> - <Item vertical> - <Label required>Password</Label> - <ValidatedField - component={PasswordField} - name="password" - validate={[required]} - /> - </Item> - </Row> - <Row mb={2}> - <Item vertical> - <Label required>Confirm password</Label> - <ValidatedField - component={PasswordField} - name="confirmPassword" - validate={[required]} - /> - </Item> - </Row> - </Fragment> + <PasswordValidation formLabel="Password" formName="signUpInvitation" /> ) const ForgotEmailForm = () => ( diff --git a/packages/components-faraday/src/components/SignUp/utils.js b/packages/components-faraday/src/components/SignUp/utils.js index 3a9fd998810f022e1f270db62c10ab020a8bf29b..b7cf7a37f3991b5b01254f063403143e417e09be 100644 --- a/packages/components-faraday/src/components/SignUp/utils.js +++ b/packages/components-faraday/src/components/SignUp/utils.js @@ -5,7 +5,11 @@ import { loginUser } from 'pubsweet-component-login/actions' import { handleFormError } from '../utils' -export const parseSignupAuthor = ({ token, confirmPassword, ...values }) => ({ +export const parseSignupAuthor = ({ + token, + confirmNewPassword, + ...values +}) => ({ ...values, }) @@ -28,7 +32,10 @@ export const login = (dispatch, values, history) => export const confirmUser = (email, token, history) => (values, dispatch) => { const request = { ...values, email, token } if (values) { - return create('/users/reset-password', omit(request, ['confirmPassword'])) + return create( + '/users/reset-password', + omit(request, ['confirmNewPassword']), + ) .then(r => { const { username } = r const { password } = values diff --git a/packages/components-faraday/src/components/UserProfile/ChangePasswordPage.js b/packages/components-faraday/src/components/UserProfile/ChangePasswordPage.js index 21ab0285bcfe8956990d035f028a604cd58ba332..03df4ad73115052c960a36eb02adb78244426a2a 100644 --- a/packages/components-faraday/src/components/UserProfile/ChangePasswordPage.js +++ b/packages/components-faraday/src/components/UserProfile/ChangePasswordPage.js @@ -11,48 +11,34 @@ import { Text, Label, ShadowedBox, + PasswordValidation, + changePasswordValidator, } from 'pubsweet-component-faraday-ui' -import { - changePasswordValidator, - onSubmitChangePassword as onSubmit, -} from '../utils' +import { onSubmitChangePassword as onSubmit } from '../utils' const PasswordField = input => <TextField {...input} type="password" /> +const containerPadding = { + pt: 4, + pb: 4, + pl: 4, + pr: 4, +} const ChangePassword = ({ history, handleSubmit, error }) => ( - <ShadowedBox center mt={10}> + <ShadowedBox center mt={10} {...containerPadding}> <H2>Change Password</H2> - <Row mt={3}> + <Row mb={2} mt={3}> <Item vertical> <Label required>Current Password</Label> <ValidatedField component={PasswordField} - name="password" - validate={[required]} - /> - </Item> - </Row> - <Row mt={2}> - <Item vertical> - <Label required>New Password</Label> - <ValidatedField - component={PasswordField} - name="newPassword" - validate={[required]} - /> - </Item> - </Row> - <Row mt={2}> - <Item vertical> - <Label required>Re-type password</Label> - <ValidatedField - component={PasswordField} - name="confirmNewPassword" + name="currentPassword" validate={[required]} /> </Item> </Row> + <PasswordValidation formLabel="New Password" formName="changePassword" /> {error && ( <Row mt={1}> <Item> @@ -61,7 +47,7 @@ const ChangePassword = ({ history, handleSubmit, error }) => ( </Row> )} <Row /> - <Row justify="space-between" mt={3}> + <Row justify="space-between"> <Button onClick={history.goBack}>Back</Button> <Button onClick={handleSubmit} primary> Update password diff --git a/packages/components-faraday/src/components/utils.js b/packages/components-faraday/src/components/utils.js index b6393a018f20e8bcf17c64e156e4aa68fed40adc..0f1f81c6b4cbb2fba9962d68d2040ceed2e0e1c5 100644 --- a/packages/components-faraday/src/components/utils.js +++ b/packages/components-faraday/src/components/utils.js @@ -101,35 +101,6 @@ export const redirectToError = redirectFn => err => { errorText || 'Something went wrong. Please try again.', ) } - -export const passwordValidator = values => { - const errors = {} - if (!values.password) { - errors.password = 'Required' - } - if (!values.confirmPassword) { - errors.confirmPassword = 'Required' - } else if (values.confirmPassword !== values.password) { - errors.confirmPassword = "Passwords don't match." - } - - return errors -} - -export const changePasswordValidator = values => { - const errors = {} - if (!values.password) { - errors.password = 'Required' - } - if (!values.newPassword) { - errors.newPassword = 'Required' - } else if (values.newPassword !== values.confirmNewPassword) { - errors.confirmNewPassword = "Passwords don't match." - } - - return errors -} - export const parseSearchParams = url => { const params = new URLSearchParams(url) const parsedObject = {} @@ -175,11 +146,11 @@ export const saveUserDetails = (userId, values) => dispatch => }) export const onSubmitChangePassword = ( - { password, newPassword }, + { currentPassword, password }, dispatch, { history }, ) => - create(`/users/change-password`, { password, newPassword }) + create(`/users/change-password`, { currentPassword, password }) .then(() => { history.goBack() }) diff --git a/packages/hindawi-theme/src/index.js b/packages/hindawi-theme/src/index.js index 39b27b8678e151241a52176e2faeb3ca55074292..fa72ae39d8eb011a3f3f752d89f6c74295f1558d 100644 --- a/packages/hindawi-theme/src/index.js +++ b/packages/hindawi-theme/src/index.js @@ -129,6 +129,7 @@ const hindawiTheme = { // font sizes fontSizeBase: '14px', + fontSizeBaseMedium: '13px', fontSizeBaseSmall: '10px', fontSizeHeading1: '30px', diff --git a/packages/pubsweet-component-login/CHANGELOG.md b/packages/pubsweet-component-login/CHANGELOG.md deleted file mode 100644 index 053b9a9ceb89f38e0ccc02a60386a493de816d58..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/CHANGELOG.md +++ /dev/null @@ -1,204 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -<a name="1.2.0"></a> -# [1.2.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.18...pubsweet-component-login@1.2.0) (2018-11-05) - - -### Features - -* GraphQL Login component ([70df3de](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/70df3de)) -* GraphQL Xpub submit component ([ba07060](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/ba07060)) - - - - -<a name="1.1.18"></a> -## [1.1.18](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.17...pubsweet-component-login@1.1.18) (2018-10-08) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.17"></a> -## [1.1.17](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.16...pubsweet-component-login@1.1.17) (2018-09-27) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.16"></a> -## [1.1.16](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.15...pubsweet-component-login@1.1.16) (2018-09-19) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.15"></a> -## [1.1.15](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.14...pubsweet-component-login@1.1.15) (2018-09-06) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.14"></a> -## [1.1.14](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.13...pubsweet-component-login@1.1.14) (2018-09-04) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.13"></a> -## [1.1.13](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.12...pubsweet-component-login@1.1.13) (2018-08-20) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.12"></a> -## [1.1.12](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.11...pubsweet-component-login@1.1.12) (2018-08-17) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.11"></a> -## [1.1.11](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.10...pubsweet-component-login@1.1.11) (2018-08-02) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.10"></a> -## [1.1.10](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.9...pubsweet-component-login@1.1.10) (2018-07-27) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.9"></a> -## [1.1.9](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.8...pubsweet-component-login@1.1.9) (2018-07-12) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.8"></a> -## [1.1.8](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.7...pubsweet-component-login@1.1.8) (2018-07-09) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.7"></a> -## [1.1.7](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.6...pubsweet-component-login@1.1.7) (2018-07-03) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.6"></a> -## [1.1.6](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.5...pubsweet-component-login@1.1.6) (2018-07-02) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.5"></a> -## [1.1.5](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.4...pubsweet-component-login@1.1.5) (2018-06-28) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.4"></a> -## [1.1.4](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.3...pubsweet-component-login@1.1.4) (2018-06-28) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.3"></a> -## [1.1.3](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.2...pubsweet-component-login@1.1.3) (2018-06-19) - - -### Bug Fixes - -* **pubsweet-ui:** tests are failing ([0e57798](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/0e57798)) - - - - -<a name="1.1.2"></a> -## [1.1.2](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.1...pubsweet-component-login@1.1.2) (2018-04-03) - - - - -**Note:** Version bump only for package pubsweet-component-login - -<a name="1.1.1"></a> -## [1.1.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.1.0...pubsweet-component-login@1.1.1) (2018-03-15) - - -### Bug Fixes - -* **login:** add missing recompose dependency ([a3b5a80](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/a3b5a80)), closes [#353](https://gitlab.coko.foundation/pubsweet/pubsweet/issues/353) - - - - -<a name="1.1.0"></a> -# [1.1.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.0.1...pubsweet-component-login@1.1.0) (2018-03-05) - - -### Bug Fixes - -* **components:** login example ([6dfd66c](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/6dfd66c)) -* **components:** login tests were failing after refactor ([62be047](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/62be047)) -* **components:** signup and login error examples ([3f991ec](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/3f991ec)) - - -### Features - -* **elife-theme:** add elife theme ([e406e0d](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/e406e0d)) - - - - -<a name="1.0.1"></a> - -## [1.0.1](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@1.0.0...pubsweet-component-login@1.0.1) (2018-02-08) - -### Bug Fixes - -* **components:** update react-router-redux version to match client ([3d257ef](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/3d257ef)) - -<a name="1.0.0"></a> - -# [1.0.0](https://gitlab.coko.foundation/pubsweet/pubsweet/compare/pubsweet-component-login@0.6.0...pubsweet-component-login@1.0.0) (2018-02-02) - -### Features - -* **client:** upgrade React to version 16 ([626cf59](https://gitlab.coko.foundation/pubsweet/pubsweet/commit/626cf59)), closes [#65](https://gitlab.coko.foundation/pubsweet/pubsweet/issues/65) - -### BREAKING CHANGES - -* **client:** Upgrade React to version 16 diff --git a/packages/pubsweet-component-login/Login.jsx b/packages/pubsweet-component-login/Login.jsx deleted file mode 100644 index 6cc5a2d8597fa53e053e4c5615f2502093e17280..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/Login.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Field } from 'formik' -import { isEmpty } from 'lodash' -import { - CenteredColumn, - ErrorText, - H1, - Link, - Button, - TextField, -} from '@pubsweet/ui' -import styled from 'styled-components' - -// These enable tests to select components -const Signup = styled.div`` -const ResetPassword = styled.div`` - -const UsernameInput = props => <TextField label="Username" {...props.field} /> -const PasswordInput = props => ( - <TextField label="Password" {...props.field} type="password" /> -) - -const Login = ({ - errors, - handleSubmit, - signup = true, - passwordReset = true, -}) => ( - <CenteredColumn small> - <H1>Login</H1> - - {!isEmpty(errors) && <ErrorText>{errors}</ErrorText>} - <form onSubmit={handleSubmit}> - <Field component={UsernameInput} name="username" /> - <Field component={PasswordInput} name="password" /> - <Button primary type="submit"> - Login - </Button> - </form> - - {signup && ( - <Signup> - <span>Don't have an account? </span> - <Link to="/signup">Sign up</Link> - </Signup> - )} - - {passwordReset && ( - <ResetPassword> - <span>Forgot your password? </span> - <Link to="/password-reset">Reset password</Link> - </ResetPassword> - )} - </CenteredColumn> -) - -Login.propTypes = { - error: PropTypes.string, - actions: PropTypes.object, - location: PropTypes.object, - signup: PropTypes.bool, - passwordReset: PropTypes.bool, -} - -// used by tests -export { Login, ErrorText, Signup, ResetPassword } - -// used by consumers -export default Login diff --git a/packages/pubsweet-component-login/Login.md b/packages/pubsweet-component-login/Login.md deleted file mode 100644 index 8efa765dd57c83028d3d4e6d9885797bac8245b7..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/Login.md +++ /dev/null @@ -1,40 +0,0 @@ -A login form - -```js -const { withFormik } = require('formik') - -const LoginForm = withFormik({ - initialValues: { - username: '', - password: '', - }, - mapPropsToValues: props => ({ - username: props.username, - password: props.password, - }), - displayName: 'login', - handleSubmit: val => console.log(val), -})(Login) -;<LoginForm /> -``` - -Which can have an error message: - -```js -const { withFormik } = require('formik') - -const LoginForm = withFormik({ - initialValues: { - username: '', - password: '', - }, - mapPropsToValues: props => ({ - username: props.username, - password: props.password, - }), - displayName: 'login', - handleSubmit: (values, { setErrors }) => - setErrors('Wrong username or password.'), -})(Login) -;<LoginForm /> -``` diff --git a/packages/pubsweet-component-login/Login.test.jsx b/packages/pubsweet-component-login/Login.test.jsx deleted file mode 100644 index f696b5eaa420beee8fb6fdf3311461b1ed28f97a..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/Login.test.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import { shallow } from 'enzyme' -import React from 'react' - -import { Login, ErrorText, Signup, ResetPassword } from './Login' - -describe('<Login/>', () => { - const makeWrapper = (props = {}) => shallow(<Login {...props} />) - - it('renders the login form', () => { - expect(makeWrapper()).toMatchSnapshot() - }) - - it('shows error', () => { - const wrapper = makeWrapper({ errors: 'Yikes!' }) - expect(wrapper.find(ErrorText)).toHaveLength(1) - }) - - it('can hide sign up link', () => { - const wrapper1 = makeWrapper() - const wrapper2 = makeWrapper({ signup: false }) - expect(wrapper1.find(Signup)).toHaveLength(1) - expect(wrapper2.find(Signup)).toHaveLength(0) - }) - - it('can hide password reset link', () => { - const wrapper1 = makeWrapper() - const wrapper2 = makeWrapper({ passwordReset: false }) - expect(wrapper1.find(ResetPassword)).toHaveLength(1) - expect(wrapper2.find(ResetPassword)).toHaveLength(0) - }) - - it('triggers submit handler', () => { - const handleSubmit = jest.fn() - const wrapper = makeWrapper({ handleSubmit }) - wrapper.find('form').simulate('submit') - expect(handleSubmit).toHaveBeenCalled() - }) -}) diff --git a/packages/pubsweet-component-login/LoginContainer.js b/packages/pubsweet-component-login/LoginContainer.js deleted file mode 100644 index 250dd2d75b9adeb1686805b4a9d8ecd7d8b4291b..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/LoginContainer.js +++ /dev/null @@ -1,26 +0,0 @@ -import { withFormik } from 'formik' -import { compose } from 'recompose' -import { connect } from 'react-redux' -import { loginUser } from './actions' - -import Login from './Login' -import redirectPath from './redirect' - -const handleSubmit = (values, { props: { dispatch, location }, setErrors }) => { - dispatch(loginUser(values, redirectPath({ location }), setErrors)) -} - -const enhancedFormik = withFormik({ - initialValues: { - username: '', - password: '', - }, - mapPropsToValues: props => ({ - username: props.username, - password: props.password, - }), - displayName: 'login', - handleSubmit, -})(Login) - -export default compose(connect(state => state))(enhancedFormik) diff --git a/packages/pubsweet-component-login/__snapshots__/Login.test.jsx.snap b/packages/pubsweet-component-login/__snapshots__/Login.test.jsx.snap deleted file mode 100644 index c37d2096241bcb88782ac550b9bd8c8f121e1e52..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/__snapshots__/Login.test.jsx.snap +++ /dev/null @@ -1,473 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`<Login/> renders the login form 1`] = ` -ShallowWrapper { - Symbol(enzyme.__root__): [Circular], - Symbol(enzyme.__unrendered__): <Login />, - Symbol(enzyme.__renderer__): Object { - "batchedUpdates": [Function], - "getNode": [Function], - "render": [Function], - "simulateError": [Function], - "simulateEvent": [Function], - "unmount": [Function], - }, - Symbol(enzyme.__node__): Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <styled.h1> - Login - </styled.h1>, - false, - <form> - <C - component={[Function]} - name="username" - /> - <C - component={[Function]} - name="password" - /> - <styled.button - primary={true} - type="submit" - > - Login - </styled.button> - </form>, - <styled.div> - <span> - Don't have an account? - </span> - <Styled(Link) - to="/signup" - > - Sign up - </Styled(Link)> - </styled.div>, - <styled.div> - <span> - Forgot your password? - </span> - <Styled(Link) - to="/password-reset" - > - Reset password - </Styled(Link)> - </styled.div>, - ], - "small": true, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Login", - }, - "ref": null, - "rendered": "Login", - "type": [Function], - }, - false, - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": Array [ - <C - component={[Function]} - name="username" - />, - <C - component={[Function]} - name="password" - />, - <styled.button - primary={true} - type="submit" - > - Login - </styled.button>, - ], - "onSubmit": undefined, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "function", - "props": Object { - "component": [Function], - "name": "username", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "function", - "props": Object { - "component": [Function], - "name": "password", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Login", - "primary": true, - "type": "submit", - }, - "ref": null, - "rendered": "Login", - "type": [Function], - }, - ], - "type": "form", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <span> - Don't have an account? - </span>, - <Styled(Link) - to="/signup" - > - Sign up - </Styled(Link)>, - ], - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": "Don't have an account? ", - }, - "ref": null, - "rendered": "Don't have an account? ", - "type": "span", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Sign up", - "to": "/signup", - }, - "ref": null, - "rendered": "Sign up", - "type": [Function], - }, - ], - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <span> - Forgot your password? - </span>, - <Styled(Link) - to="/password-reset" - > - Reset password - </Styled(Link)>, - ], - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": "Forgot your password? ", - }, - "ref": null, - "rendered": "Forgot your password? ", - "type": "span", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Reset password", - "to": "/password-reset", - }, - "ref": null, - "rendered": "Reset password", - "type": [Function], - }, - ], - "type": [Function], - }, - ], - "type": [Function], - }, - Symbol(enzyme.__nodes__): Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <styled.h1> - Login - </styled.h1>, - false, - <form> - <C - component={[Function]} - name="username" - /> - <C - component={[Function]} - name="password" - /> - <styled.button - primary={true} - type="submit" - > - Login - </styled.button> - </form>, - <styled.div> - <span> - Don't have an account? - </span> - <Styled(Link) - to="/signup" - > - Sign up - </Styled(Link)> - </styled.div>, - <styled.div> - <span> - Forgot your password? - </span> - <Styled(Link) - to="/password-reset" - > - Reset password - </Styled(Link)> - </styled.div>, - ], - "small": true, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Login", - }, - "ref": null, - "rendered": "Login", - "type": [Function], - }, - false, - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": Array [ - <C - component={[Function]} - name="username" - />, - <C - component={[Function]} - name="password" - />, - <styled.button - primary={true} - type="submit" - > - Login - </styled.button>, - ], - "onSubmit": undefined, - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "function", - "props": Object { - "component": [Function], - "name": "username", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "function", - "props": Object { - "component": [Function], - "name": "password", - }, - "ref": null, - "rendered": null, - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Login", - "primary": true, - "type": "submit", - }, - "ref": null, - "rendered": "Login", - "type": [Function], - }, - ], - "type": "form", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <span> - Don't have an account? - </span>, - <Styled(Link) - to="/signup" - > - Sign up - </Styled(Link)>, - ], - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": "Don't have an account? ", - }, - "ref": null, - "rendered": "Don't have an account? ", - "type": "span", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Sign up", - "to": "/signup", - }, - "ref": null, - "rendered": "Sign up", - "type": [Function], - }, - ], - "type": [Function], - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": Array [ - <span> - Forgot your password? - </span>, - <Styled(Link) - to="/password-reset" - > - Reset password - </Styled(Link)>, - ], - }, - "ref": null, - "rendered": Array [ - Object { - "instance": null, - "key": undefined, - "nodeType": "host", - "props": Object { - "children": "Forgot your password? ", - }, - "ref": null, - "rendered": "Forgot your password? ", - "type": "span", - }, - Object { - "instance": null, - "key": undefined, - "nodeType": "class", - "props": Object { - "children": "Reset password", - "to": "/password-reset", - }, - "ref": null, - "rendered": "Reset password", - "type": [Function], - }, - ], - "type": [Function], - }, - ], - "type": [Function], - }, - ], - Symbol(enzyme.__options__): Object { - "adapter": ReactSixteenAdapter { - "options": Object { - "enableComponentDidUpdateOnSetState": true, - "lifecycles": Object { - "componentDidUpdate": Object { - "onSetState": true, - }, - "getDerivedStateFromProps": true, - "getSnapshotBeforeUpdate": true, - "setState": Object { - "skipsComponentDidUpdateOnNullish": true, - }, - }, - }, - }, - }, -} -`; diff --git a/packages/pubsweet-component-login/actions.js b/packages/pubsweet-component-login/actions.js deleted file mode 100644 index e74909742d021bb2f66d46674e968b5a3eed2919..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/actions.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as api from 'pubsweet-client/src/helpers/api' -import { - LOGIN_REQUEST, - LOGIN_SUCCESS, - LOGIN_FAILURE, - LOGOUT_SUCCESS, - LOGOUT_REQUEST, -} from 'pubsweet-client/src/actions/types' - -import { push } from 'react-router-redux' - -// TODO: This will break when rendered on a server -const localStorage = window.localStorage || undefined - -// There are three possible states for our login -// process and we need actions for each of them -function loginRequest(credentials) { - return { - type: LOGIN_REQUEST, - credentials, - } -} - -function loginSuccess(user) { - return { - type: LOGIN_SUCCESS, - token: user.token, - user, - } -} - -function loginFailure(message) { - return { - type: LOGIN_FAILURE, - error: message, - } -} - -// Calls the API to get a token and -// dispatches actions along the way -export function loginUser(credentials, redirectTo, setErrors) { - return dispatch => { - dispatch(loginRequest(credentials)) - return api.create('/users/authenticate', credentials).then( - user => { - localStorage.setItem('token', user.token) - dispatch(loginSuccess(user)) - if (redirectTo) dispatch(push(redirectTo)) - }, - err => { - setErrors(JSON.parse(err.response).message) - dispatch(loginFailure(err)) - }, - ) - } -} - -function logoutRequest() { - return { - type: LOGOUT_REQUEST, - isFetching: true, - isAuthenticated: true, - } -} - -function logoutSuccess() { - return { - type: LOGOUT_SUCCESS, - isFetching: false, - isAuthenticated: false, - } -} - -// Logs the user out -// Since we are using JWTs, we just need to remove the token -// from localStorage. -export function logoutUser(redirectTo) { - return dispatch => { - dispatch(logoutRequest()) - localStorage.removeItem('token') - dispatch(logoutSuccess()) - if (redirectTo) dispatch(push(redirectTo)) - } -} diff --git a/packages/pubsweet-component-login/graphql/LoginContainer.js b/packages/pubsweet-component-login/graphql/LoginContainer.js deleted file mode 100644 index 83715d8019cbd8eb24bf086d9b7ebdb733daf80e..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/graphql/LoginContainer.js +++ /dev/null @@ -1,43 +0,0 @@ -import { compose } from 'recompose' -import { withFormik } from 'formik' -import { graphql } from 'react-apollo' - -import mutations from './mutations' -import Login from '../Login' -import redirectPath from '../redirect' - -const localStorage = window.localStorage || undefined - -const handleSubmit = (values, { props, setSubmitting, setErrors }) => - props - .loginUser({ variables: { input: values } }) - .then(({ data, errors }) => { - if (!errors) { - localStorage.setItem('token', data.loginUser.token) - props.history.push(redirectPath({ location: props.location })) - setSubmitting(true) - } - }) - .catch(e => { - if (e.graphQLErrors) { - setSubmitting(false) - setErrors(e.graphQLErrors[0].message) - } - }) - -const enhancedFormik = withFormik({ - initialValues: { - username: '', - password: '', - }, - mapPropsToValues: props => ({ - username: props.username, - password: props.password, - }), - displayName: 'login', - handleSubmit, -})(Login) - -export default compose(graphql(mutations.LOGIN_USER, { name: 'loginUser' }))( - enhancedFormik, -) diff --git a/packages/pubsweet-component-login/graphql/mutations/index.js b/packages/pubsweet-component-login/graphql/mutations/index.js deleted file mode 100644 index ee44b493da283da233533820994539bc15529084..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/graphql/mutations/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import gql from 'graphql-tag' - -const LOGIN_USER = gql` - mutation($input: LoginUserInput) { - loginUser(input: $input) { - token - } - } -` - -module.exports = { - LOGIN_USER, -} diff --git a/packages/pubsweet-component-login/index.js b/packages/pubsweet-component-login/index.js deleted file mode 100644 index 155554e77ad3dfe5c27ec6c2d4b5b623dcb3d135..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/index.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - frontend: { - components: [() => require('./LoginContainer')], - actions: () => require('./actions'), - reducers: () => require('./reducers'), - }, -} diff --git a/packages/pubsweet-component-login/package.json b/packages/pubsweet-component-login/package.json deleted file mode 100644 index 5f5efb1eead08bc87ed1a89e24fb76ab094a9ffb..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "pubsweet-component-login", - "version": "1.2.0", - "description": "Basic login component for PubSweet", - "main": "index.js", - "author": "Collaborative Knowledge Foundation", - "license": "MIT", - "dependencies": { - "@pubsweet/ui": "^9.0.2", - "formik": "1.3.0", - "prop-types": "^15.5.10", - "react-redux": "^5.0.6", - "react-router-dom": "^4.2.2", - "react-router-redux": "^5.0.0-alpha.9", - "recompose": "^0.26.0" - }, - "peerDependencies": { - "pubsweet-client": ">=1.0.0", - "react": ">=15" - }, - "repository": { - "type": "git", - "url": "https://gitlab.coko.foundation/pubsweet/pubsweet", - "path": "Login" - } -} diff --git a/packages/pubsweet-component-login/redirect.js b/packages/pubsweet-component-login/redirect.js deleted file mode 100644 index 53d58f1e3e4d1fe5a5d53584048d7a389c340887..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/redirect.js +++ /dev/null @@ -1,10 +0,0 @@ -import { get } from 'lodash' -import config from 'config' - -const allowedRedirect = pathname => - !['/logout', '/login', '/signup'].includes(pathname) - -export default ({ location: { state } }) => - state && state.from && allowedRedirect(state.from.pathname) - ? state.from.pathname - : get(config, 'pubsweet-client.login-redirect', '/') diff --git a/packages/pubsweet-component-login/reducers.js b/packages/pubsweet-component-login/reducers.js deleted file mode 100644 index d1f637a01e310491d842ac045d78ac1a0a879527..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/reducers.js +++ /dev/null @@ -1,58 +0,0 @@ -import { - LOGIN_REQUEST, - LOGIN_SUCCESS, - LOGIN_FAILURE, - LOGOUT_SUCCESS, - LOGOUT_REQUEST, -} from 'pubsweet-client/src/actions/types' - -// TODO: This will break when rendered on a server -const localStorage = window.localStorage || undefined - -export default function userLogin( - state = { - isFetching: false, - isAuthenticated: false, - token: localStorage.getItem('token'), - }, - action, -) { - switch (action.type) { - case LOGIN_REQUEST: - return { - ...state, - isFetching: true, - isAuthenticated: false, - username: action.credentials.username, - } - case LOGIN_SUCCESS: - return { - ...state, - isFetching: false, - isAuthenticated: true, - user: action.user, - token: action.token, - } - case LOGIN_FAILURE: - return { - ...state, - isFetching: false, - isAuthenticated: false, - error: action.error, - } - case LOGOUT_SUCCESS: - return { - ...state, - isFetching: false, - isAuthenticated: false, - } - case LOGOUT_REQUEST: - return { - ...state, - isFetching: false, - isAuthenticated: false, - } - default: - return state - } -} diff --git a/packages/pubsweet-component-login/reducers.test.js b/packages/pubsweet-component-login/reducers.test.js deleted file mode 100644 index a3733cf35bff6ae0ac033e66fdec726ce557d509..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/reducers.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import { - LOGIN_FAILURE, - LOGIN_REQUEST, - LOGIN_SUCCESS, - LOGOUT_REQUEST, - LOGOUT_SUCCESS, -} from 'pubsweet-client/src/actions/types' - -jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => undefined) - -const reducer = require('./reducers').default - -describe('Login reducer', () => { - it('returns initial state', () => { - const newState = reducer(undefined, {}) - expect(newState).toEqual({ - isFetching: false, - isAuthenticated: false, - token: undefined, - }) - }) - - it('stores username on login request', () => { - const action = { - type: LOGIN_REQUEST, - credentials: { username: 'milo minderbinder' }, - } - const newState = reducer(undefined, action) - expect(newState).toMatchObject({ - isFetching: true, - username: 'milo minderbinder', - }) - }) - - it('stores user and token on login success', () => { - const action = { - type: LOGIN_SUCCESS, - user: { username: 'nurse duckett' }, - token: 't0k3n', - } - const newState = reducer(undefined, action) - expect(newState).toMatchObject({ - isAuthenticated: true, - user: action.user, - token: action.token, - }) - }) - - it('stores error on login failure', () => { - const action = { type: LOGIN_FAILURE, error: new Error('Flies in eyes') } - const newState = reducer({ isAuthenticated: true }, action) - expect(newState).toMatchObject({ - isAuthenticated: false, - error: action.error, - }) - }) - - it('logs out on request', () => { - const action = { type: LOGOUT_REQUEST } - const newState = reducer({ isAuthenticated: true }, action) - expect(newState).toMatchObject({ - isAuthenticated: false, - }) - }) - - it('logs out on logout success', () => { - const action = { type: LOGOUT_SUCCESS } - const newState = reducer({ isAuthenticated: true }, action) - expect(newState).toMatchObject({ - isAuthenticated: false, - }) - }) -}) diff --git a/packages/pubsweet-component-login/types.js b/packages/pubsweet-component-login/types.js deleted file mode 100644 index 3b66211c7193bd42a3e8aa9f3c63b46a57172a43..0000000000000000000000000000000000000000 --- a/packages/pubsweet-component-login/types.js +++ /dev/null @@ -1,7 +0,0 @@ -export const LOGIN_REQUEST = 'LOGIN_REQUEST' -export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' -export const LOGIN_FAILURE = 'LOGIN_FAILURE' - -export const LOGOUT_REQUEST = 'LOGOUT_REQUEST' -export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS' -export const LOGOUT_FAILURE = 'LOGOUT_FAILURE' diff --git a/packages/styleguide/src/webpack-config.js b/packages/styleguide/src/webpack-config.js index b5132f279680d6c8bc932dfa0a0e460e91961b35..29f13854e2cb65c8674d4f916868ec908960429f 100644 --- a/packages/styleguide/src/webpack-config.js +++ b/packages/styleguide/src/webpack-config.js @@ -4,8 +4,6 @@ process.env.NODE_ENV = 'development' const webpack = require('webpack') const path = require('path') -// const nodeExternals = require('webpack-node-externals') - module.exports = dir => { const include = [ path.join(dir, 'src'), diff --git a/packages/xpub-faraday/app/FaradayApp.js b/packages/xpub-faraday/app/FaradayApp.js index 565963319b75d0616677c2bb2a53cbb96b7e6eb0..6cea06bca888a569f619f63c23a46f7db80e930d 100644 --- a/packages/xpub-faraday/app/FaradayApp.js +++ b/packages/xpub-faraday/app/FaradayApp.js @@ -3,10 +3,10 @@ import { get } from 'lodash' import { connect } from 'react-redux' import styled, { css } from 'styled-components' import { th } from '@pubsweet/ui-toolkit' -import { actions } from 'pubsweet-client' import { withJournal } from 'xpub-journal' import { withRouter } from 'react-router-dom' import { compose, withHandlers } from 'recompose' +import { logoutUser } from 'pubsweet-component-login/actions' import { Logo, AppBar, @@ -70,7 +70,7 @@ export default compose( canCreateDraft: !userNotConfirmed(state), }), (dispatch, { history }) => ({ - logout: () => dispatch(actions.logoutUser()), + logout: () => dispatch(logoutUser()), createDraft: () => dispatch(createDraftSubmission(history)), }), ), diff --git a/packages/xpub-faraday/app/config/journal/recommendations.js b/packages/xpub-faraday/app/config/journal/recommendations.js index 736effd199a5801309f3fe4e1a4f622c88cc33ad..99b50aa2d2eba35e06e7a127dd19a6631eed2b69 100644 --- a/packages/xpub-faraday/app/config/journal/recommendations.js +++ b/packages/xpub-faraday/app/config/journal/recommendations.js @@ -15,4 +15,8 @@ module.exports = [ value: 'reject', label: 'Reject', }, + { + value: 'revision', + label: 'Revision', + }, ] diff --git a/packages/xpub-faraday/app/config/journal/statuses.js b/packages/xpub-faraday/app/config/journal/statuses.js index 7170160ee9bea0077972884445c298422eb1e461..daa87a04be839c0aadd01a46c595705604b966aa 100644 --- a/packages/xpub-faraday/app/config/journal/statuses.js +++ b/packages/xpub-faraday/app/config/journal/statuses.js @@ -7,7 +7,7 @@ module.exports = { }, admin: { label: 'Complete Submission', - needsAttention: true, + needsAttention: false, }, }, technicalChecks: { 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..df9ecde417ba4e3a4b01692629cbb64c9027bf47 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' @@ -230,7 +235,10 @@ async function applyAuthenticatedUserPolicy(user, operation, object, context) { }) } - if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { + if ( + get(object, 'current.type') === 'user' && + get(object, 'current.id') === user.id + ) { return { filter: body => pick(body, [ diff --git a/packages/xpub-faraday/config/components.json b/packages/xpub-faraday/config/components.json index aebff0aa7d8026a5b1ac2ca58f598261ba3c5d8d..3083694230c19476ca18ffaac0a8d84ed7bf67e5 100644 --- a/packages/xpub-faraday/config/components.json +++ b/packages/xpub-faraday/config/components.json @@ -1,5 +1,4 @@ [ - "pubsweet-component-login", "pubsweet-component-wizard", "pubsweet-component-modal", "pubsweet-components-faraday", diff --git a/packages/xpub-faraday/config/default.js b/packages/xpub-faraday/config/default.js index 295d5346f74489e99d806bd4f79ad97215c6f368..13eed68fde3e75c0cea41cde6d77d128783590d3 100644 --- a/packages/xpub-faraday/config/default.js +++ b/packages/xpub-faraday/config/default.js @@ -148,4 +148,7 @@ module.exports = { editor: 'editorRecommendation', }, }, + passwordStrengthRegex: new RegExp( + '^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&,.?;\'*><)([}{}":`~+=_-\\|/])(?=.{6,128})', + ), } diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index 8afc3c8a092b585a8e5499802e9b33fc993d550e..562ea5242cef0bcd0c3268496b72e70be656cf4a 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -126,6 +126,7 @@ module.exports = { 'reject', 'publish', 'revise', + 'revision', 'major', 'minor', 'return-to-handling-editor', diff --git a/packages/xpub-faraday/tests/config/authsome-helpers.test.js b/packages/xpub-faraday/tests/config/authsome-helpers.test.js index c5634168fdf6ab29134874c7c3fa9ee96183b2d0..10571579af13e2834aa58b9ca6a4f7fe72971397 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(8) + }) + + 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(7) + }) + + 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 = [ diff --git a/yarn.lock b/yarn.lock index bc890f487d57965d8cc122f57628532144c91457..0800a7fa664a6c4a705bd6b8d6c6648f94854b68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10842,6 +10842,19 @@ pubsweet-client@^7.0.0: styled-normalize "^8.0.4" subscriptions-transport-ws "^0.9.12" +pubsweet-component-login@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/pubsweet-component-login/-/pubsweet-component-login-1.2.3.tgz#4e6046c793c53db806a3ac3b84947c23257da27d" + integrity sha512-SzyuDG2Kk4Wrd7dG5cd2dD3pMhExWo/O891cMCqTYRBXJ/Qg2RG7g8/8Dbsolzg45phtQIjCjDRBDLDQK1OMfg== + dependencies: + "@pubsweet/ui" "^9.0.2" + formik "1.3.0" + prop-types "^15.5.10" + react-redux "^5.0.6" + react-router-dom "^4.2.2" + react-router-redux "^5.0.0-alpha.9" + recompose "^0.26.0" + pubsweet-server@^10.0.0, pubsweet-server@^10.1.3: version "10.1.3" resolved "https://registry.yarnpkg.com/pubsweet-server/-/pubsweet-server-10.1.3.tgz#f3286608ecc2f0280eb459412ff134897ddfaa37"