Skip to content
Snippets Groups Projects
Commit 9cf9528a authored by Alexandru Munteanu's avatar Alexandru Munteanu
Browse files

feat(he-invite-reviewers): add reviewers table, resend and revoke invitations

parent 6d46f24d
No related branches found
No related tags found
2 merge requests!58Sprint #20 - Goal - Reviewers submit report,!50HIN-854: Invite + resend/revoke reviewer invitations
Showing
with 219 additions and 256 deletions
......@@ -40,16 +40,18 @@ export const currentUserIs = ({ currentUser: { user } }, role) => {
}
}
export const canInviteReviewers = ({ currentUser: { user } }, project) => {
const status = get(project, 'status')
if (!status || status === 'rejected' || status === 'published') return false
const cannotInviteReviewersStatuses = ['draft', 'rejected', 'published']
export const canInviteReviewers = (state, collection) => {
if (
cannotInviteReviewersStatuses.includes(get(collection, 'status', 'draft'))
)
return false
const handlingEditor = get(project, 'handlingEditor')
const isAdmin = get(user, 'admin')
const isEic = get(user, 'editorInChief')
const isAccepted = get(handlingEditor, 'isAccepted')
const heId = get(handlingEditor, 'id')
return isAccepted && (user.id === heId || isAdmin || isEic)
const user = selectCurrentUser(state)
const isStaff = currentUserIs(state, 'adminEiC')
const { isAccepted, id: heId } = get(collection, 'handlingEditor', {})
return isAccepted && (user.id === heId || isStaff)
}
export const getUserToken = ({ currentUser }) =>
......@@ -195,3 +197,15 @@ export const newestFirstParseDashboard = (state, items) =>
handlingEditor: parseInvitedHE(item.handlingEditor, state, item.id),
}))
.value()
export const getInvitationsWithReviewersForFragment = (state, fragmentId) =>
chain(state)
.get(`fragments.${fragmentId}.invitations`, [])
.filter(invitation => invitation.role === 'reviewer')
.map(invitation => ({
...invitation,
person: get(state, 'users.users', []).find(
reviewer => reviewer.id === invitation.userId,
),
}))
.value()
......@@ -100,6 +100,7 @@ export default compose(
// #region styles
const Root = styled.div`
background-color: #eeeeee;
padding: calc(${th('gridUnit')} * 2);
`
// #endregion
......@@ -2,15 +2,16 @@ import React, { Fragment } from 'react'
import styled from 'styled-components'
import { compose, withHandlers, defaultProps, setDisplayName } from 'recompose'
import { Text, OpenModal, IconButton, marginHelper } from './'
import { Text, OpenModal, IconButton, marginHelper, withFetching } from './'
const PersonInvitation = ({
id,
withName,
hasAnswer,
isFetching,
person: { name },
revokeInvitation,
resendInvitation,
person: { name, email },
...rest
}) => (
<Root {...rest}>
......@@ -19,9 +20,12 @@ const PersonInvitation = ({
name !== 'Unassigned' && (
<Fragment>
<OpenModal
confirmText="Resend"
isFetching={isFetching}
modalKey={`resend-${id}`}
onConfirm={resendInvitation}
title="Are you sure you want to resend the invitation?"
subtitle={email}
title="Resend the invitation?"
>
{showModal => (
<IconButton
......@@ -34,11 +38,12 @@ const PersonInvitation = ({
)}
</OpenModal>
<OpenModal
confirmText="Remove invite"
confirmText="Revoke"
isFetching={isFetching}
modalKey={`revoke-${id}`}
onConfirm={revokeInvitation}
subtitle="Clicking ‘Remove’ will allow you to invite a different Handling Editor"
title="Remove invitation to Handling Editor?"
subtitle={email}
title="Revoke invitation?"
>
{showModal => (
<IconButton
......@@ -63,12 +68,19 @@ export default compose(
onRevoke: id => {},
onResend: id => {},
}),
withFetching,
withHandlers({
revokeInvitation: ({ id, onRevoke }) => props => {
typeof onRevoke === 'function' && onRevoke(id, props)
revokeInvitation: ({ id, onRevoke, setFetching }) => props => {
typeof onRevoke === 'function' && onRevoke(id, { ...props, setFetching })
},
resendInvitation: ({ id, onResend, person: { email } }) => props => {
typeof onResend === 'function' && onResend(email, props)
resendInvitation: ({
id,
onResend,
setFetching,
person: { email },
}) => props => {
typeof onResend === 'function' &&
onResend(email, { ...props, setFetching })
},
}),
setDisplayName('PersonInvitation'),
......
......@@ -57,7 +57,7 @@ export default compose(
<Text mr={1 / 2}> submitted</Text>
</Row>
) : (
<Text> {`${reviewerInvitations.length} invited`}</Text>
<Text mr={1}>{`${reviewerInvitations.length} invited`}</Text>
)
},
}),
......
import React from 'react'
import React, { Fragment } from 'react'
import { get } from 'lodash'
import styled from 'styled-components'
import { shouldUpdate } from 'recompose'
import { th } from '@pubsweet/ui-toolkit'
import { DateParser } from '@pubsweet/ui'
import { Label, PersonInvitation, Text } from '../'
const invitation = {
id: 'b4305ab6-84e6-48a3-9eb9-fbe0ec80c694',
role: 'handlingEditor',
type: 'invitation',
reason: 'because',
userId: 'cb7e3e26-6a09-4b79-a6ff-4d1235ee2381',
hasAnswer: false,
invitedOn: 713919119,
isAccepted: false,
respondedOn: 1533714034932,
person: {
id: 'cb7e3e26-6a09-4b79-a6ff-4d1235ee2381',
name: 'Toto Schilacci',
},
}
const ReviewersTable = ({
invitations,
onResendReviewerInvite,
onRevokeReviewerInvite,
}) =>
invitations.length > 0 && (
<Table>
<thead>
<tr>
<th>
<Label>Full Name</Label>
</th>
<th>
<Label>Invited on</Label>
</th>
<th>
<Label>Responded on</Label>
</th>
<th>
<Label>Submitted on</Label>
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{invitations.map((invitation, index) => (
<TableRow key={invitation.id}>
<td>
<Text>{`${get(invitation, 'person.firstName', '')} ${get(
invitation,
'person.lastName',
)}`}</Text>
{invitation.isAccepted && (
<Text customId ml={1}>{`Reviewer ${index + 1}`}</Text>
)}
</td>
<td>
<DateParser timestamp={invitation.invitedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
</td>
<td>
{invitation.respondedOn && (
<Fragment>
<DateParser timestamp={invitation.respondedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
<Text ml={1} secondary>
ACCEPTED
</Text>
</Fragment>
)}
</td>
<td>
{invitation.respondedOn && (
<DateParser timestamp={invitation.respondedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
)}
</td>
<HiddenCell>
{!invitation.hasAnswer && (
<PersonInvitation
{...invitation}
onResend={onResendReviewerInvite}
onRevoke={onRevokeReviewerInvite}
/>
)}
</HiddenCell>
</TableRow>
))}
</tbody>
</Table>
)
const reviewers = [
{
id: 1,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 2,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 11,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 21,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 12,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 22,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 13,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 23,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 113,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 213,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 123,
fullName: 'Gica Hagi',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
{
id: 223,
fullName: 'Cosmin Contra',
invitedOn: Date.now(),
respondedOn: Date.now(),
submittedOn: Date.now() - 3000000000,
},
]
const ReviewersTable = () => (
<Table>
<thead>
<tr>
<th>
<Label>Full Name</Label>
</th>
<th>
<Label>Invited on</Label>
</th>
<th>
<Label>Responded on</Label>
</th>
<th>
<Label>Submitted on</Label>
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{reviewers.map((r, index) => (
<TableRow key={r.id}>
<td>
<Text>{r.fullName}</Text>
<Text customId ml={1}>{`Reviewer ${index + 1}`}</Text>
</td>
<td>
<DateParser timestamp={r.invitedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
</td>
<td>
<DateParser timestamp={r.respondedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
<Text ml={1} secondary>
ACCEPTED
</Text>
</td>
<td>
<DateParser timestamp={r.submittedOn}>
{timestamp => <Text>{timestamp}</Text>}
</DateParser>
</td>
<HiddenCell>
<PersonInvitation {...invitation} />
</HiddenCell>
</TableRow>
))}
</tbody>
</Table>
)
export default ReviewersTable
export default shouldUpdate(() => false)(ReviewersTable)
// #region styles
const Table = styled.table`
......@@ -169,6 +90,7 @@ const Table = styled.table`
& thead {
border-bottom: 1px solid ${th('colorBorder')};
background-color: ${th('colorBackgroundHue2')};
}
& th,
......@@ -185,6 +107,7 @@ const Table = styled.table`
const HiddenCell = styled.td`
opacity: 0;
padding-top: ${th('gridUnit')};
`
const TableRow = styled.tr`
......
......@@ -53,7 +53,6 @@ const WizardAuthors = ({
isFetching,
}) => (
<Fragment>
{isFetching && <h1>loading...</h1>}
<Row alignItems="center" justify="flex-start">
<Item>
<Label>Authors</Label>
......
import React, { Fragment } from 'react'
import { H4 } from '@pubsweet/ui'
import styled from 'styled-components'
import { th } from '@pubsweet/ui-toolkit'
import {
Tag,
Tabs,
Label,
marginHelper,
ContextualBox,
ReviewersTable,
ReviewerReport,
InviteReviewers,
ReviewerBreakdown,
} from '../'
const report = {
submittedOn: Date.now(),
recommendation: 'Reject',
report: `Of all of the celestial bodies that capture our attention and
fascination as astronomers, none has a greater influence on life on
planet Earth than it’s own satellite, the moon. When you think about
it, we regard the moon with such powerful significance that unlike the
moons of other planets which we give names, we only refer to our one
and only orbiting orb as THE moon. It is not a moon. To us, it is the
one and only moon.`,
reviewer: {
fullName: 'Kenny Hudson',
reviewerNumber: 1,
},
confidentialNote: `First 10 pages feel very familiar, you should check for plagarism.`,
files: [
{ id: 'file1', name: 'file1.pdf', size: 12356 },
{ id: 'file2', name: 'file2.pdf', size: 76421 },
],
}
const ReviewerDetails = ({ fragment, onInviteReviewer }) => (
const ReviewerDetails = ({
fragment,
invitations,
onInviteReviewer,
onResendReviewerInvite,
onRevokeReviewerInvite,
}) => (
<ContextualBox
label="Reviewer details"
rightChildren={<ReviewerBreakdown fitContent fragment={fragment} mr={1} />}
......@@ -51,16 +34,7 @@ const ReviewerDetails = ({ fragment, onInviteReviewer }) => (
onClick={() => changeTab(0)}
selected={selectedTab === 0}
>
<Label>Reviewer Details</Label>
</TabButton>
<TabButton
ml={1}
mr={1}
onClick={() => changeTab(1)}
selected={selectedTab === 1}
>
<Label>Reviewer Reports</Label>
<Tag ml={1}>12</Tag>
<H4>Reviewer Details</H4>
</TabButton>
</TabsHeader>
{selectedTab === 0 && (
......@@ -69,14 +43,11 @@ const ReviewerDetails = ({ fragment, onInviteReviewer }) => (
modalKey="invite-reviewers"
onInvite={onInviteReviewer}
/>
<ReviewersTable />
</Fragment>
)}
{selectedTab === 1 && (
<Fragment>
<ReviewerReport report={report} />
<ReviewerReport report={report} />
<ReviewerReport report={report} />
<ReviewersTable
invitations={invitations}
onResendReviewerInvite={onResendReviewerInvite}
onRevokeReviewerInvite={onRevokeReviewerInvite}
/>
</Fragment>
)}
</Fragment>
......@@ -101,16 +72,20 @@ const TabButton = styled.div`
height: calc(${th('gridUnit')} * 5);
${marginHelper};
${H4} {
padding: 0 ${th('gridUnit')};
}
`
const TabsHeader = styled.div`
align-items: center;
border-bottom: 1px solid ${th('colorFurniture')};
background-color: ${th('colorBackgroundHue2')};
box-sizing: border-box;
display: flex;
justify-content: flex-start;
margin-bottom: ${th('gridUnit')};
padding: 0 calc(${th('gridUnit')} * 3);
`
// #endregion
......@@ -47,6 +47,9 @@ const ManuscriptLayout = ({
heResponseExpanded,
onHEResponse,
onInviteReviewer,
invitationsWithReviewers,
onResendReviewerInvite,
onRevokeReviewerInvite,
}) => (
<Root pb={1}>
{!isEmpty(collection) && !isEmpty(fragment) ? (
......@@ -111,10 +114,15 @@ const ManuscriptLayout = ({
/>
)}
<ReviewerDetails
fragment={fragment}
onInviteReviewer={onInviteReviewer}
/>
{get(currentUser, 'permissions.canInviteReviewers', false) && (
<ReviewerDetails
fragment={fragment}
invitations={invitationsWithReviewers}
onInviteReviewer={onInviteReviewer}
onResendReviewerInvite={onResendReviewerInvite}
onRevokeReviewerInvite={onRevokeReviewerInvite}
/>
)}
</Fragment>
) : (
<Text>Loading...</Text>
......@@ -126,8 +134,8 @@ export default ManuscriptLayout
// #region styles
const Root = styled.div`
overflow-y: auto;
min-height: 50vh;
overflow-y: visible;
min-height: 70vh;
${paddingHelper};
`
// #endregion
......@@ -24,6 +24,7 @@ import {
import { getSignedUrl } from 'pubsweet-components-faraday/src/redux/files'
import {
inviteReviewer,
revokeReviewer,
reviewerDecision,
} from 'pubsweet-components-faraday/src/redux/reviewers'
import {
......@@ -37,10 +38,12 @@ import {
canMakeRevision,
canMakeDecision,
canEditManuscript,
canInviteReviewers,
pendingHEInvitation,
currentUserIsReviewer,
canMakeRecommendation,
canOverrideTechnicalChecks,
getInvitationsWithReviewersForFragment,
} from 'pubsweet-component-faraday-selectors'
import { RemoteOpener } from 'pubsweet-component-faraday-ui'
......@@ -91,6 +94,7 @@ export default compose(
assignHandlingEditor,
createRecommendation,
revokeHandlingEditor,
getUsers: actions.getUsers,
getFragment: actions.getFragment,
getCollection: actions.getCollection,
updateVersion: actions.updateFragment,
......@@ -110,6 +114,7 @@ export default compose(
isInvitedHE: !isUndefined(pendingHEInvitation),
permissions: {
canAssignHE: canAssignHE(state, match.params.project),
canInviteReviewers: canInviteReviewers(state, collection),
canMakeRevision: canMakeRevision(state, collection, fragment),
canMakeDecision: canMakeDecision(state, collection, fragment),
canEditManuscript: canEditManuscript(state, collection, fragment),
......@@ -129,6 +134,10 @@ export default compose(
eicDecision: getFormValues('eic-decision')(state),
heInvitation: getFormValues('he-answer-invitation')(state),
},
invitationsWithReviewers: getInvitationsWithReviewersForFragment(
state,
get(fragment, 'id', ''),
),
}),
),
ConnectPage(({ currentUser }) => {
......@@ -140,12 +149,14 @@ export default compose(
withHandlers({
fetchUpdatedCollection: ({
fragment,
getUsers,
collection,
getFragment,
getCollection,
}) => () => {
getCollection({ id: collection.id })
getFragment(collection, fragment)
getUsers()
},
}),
withHandlers({
......@@ -256,6 +267,51 @@ export default compose(
setModalError('Something went wrong...')
})
},
onResendReviewerInvite: ({
fragment,
collection,
fetchUpdatedCollection,
}) => (email, { hideModal, setFetching, setModalError }) => {
setFetching(true)
inviteReviewer({
reviewerData: {
email,
role: 'reviewer',
},
fragmentId: fragment.id,
collectionId: collection.id,
})
.then(() => {
setFetching(false)
hideModal()
fetchUpdatedCollection()
})
.catch(() => {
setFetching(false)
setModalError('Something went wrong...')
})
},
onRevokeReviewerInvite: ({
fragment,
collection,
fetchUpdatedCollection,
}) => (invitationId, { hideModal, setFetching, setModalError }) => {
setFetching(true)
revokeReviewer({
invitationId,
fragmentId: fragment.id,
collectionId: collection.id,
})
.then(() => {
setFetching(false)
hideModal()
fetchUpdatedCollection()
})
.catch(() => {
setFetching(false)
setModalError('Something went wrong...')
})
},
}),
fromRenderProps(RemoteOpener, ({ toggle, expanded }) => ({
toggleAssignHE: toggle,
......
......@@ -31,19 +31,6 @@ const INVITE_REVIEWER_REQUEST = 'INVITE_REVIEWER_REQUEST'
const INVITE_REVIEWER_SUCCESS = 'INVITE_REVIEWER_SUCCESS'
const INVITE_REVIEWER_ERROR = 'INVITE_REVIEWER_ERROR'
const inviteRequest = () => ({
type: INVITE_REVIEWER_REQUEST,
})
const inviteSuccess = () => ({
type: INVITE_REVIEWER_SUCCESS,
})
const inviteError = error => ({
type: INVITE_REVIEWER_ERROR,
error,
})
// reviewer decision constants and action creators
const REVIEWER_DECISION_REQUEST = 'REVIEWER_DECISION_REQUEST'
const REVIEWER_DECISION_ERROR = 'REVIEWER_DECISION_ERROR'
......@@ -133,22 +120,10 @@ export const setReviewerPassword = reviewerBody => dispatch => {
})
}
export const revokeReviewer = (
invitationId,
collectionId,
fragmentId,
) => dispatch => {
dispatch(inviteRequest())
return remove(
export const revokeReviewer = ({ fragmentId, collectionId, invitationId }) =>
remove(
`/collections/${collectionId}/fragments/${fragmentId}/invitations/${invitationId}`,
).then(
() => dispatch(inviteSuccess()),
err => {
dispatch(inviteError(get(JSON.parse(err.response), 'error')))
throw err
},
)
}
// #endregion
// #region Actions - decision
......
......@@ -118,7 +118,7 @@ const appBarPaddingHelper = props =>
const MainContainer = styled.div`
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-y: visible;
${appBarPaddingHelper};
`
// #endregion
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment