Commit f5f9b0f9 authored by Yannis Barlas's avatar Yannis Barlas
Browse files

Reviewer redesign

parent 23463eaf
......@@ -35,6 +35,7 @@ logs/
stories/
tmp/
uploads/*
!uploads/sample-image.jpg
CONTRIBUTING.md
# development config files
......
......@@ -12,6 +12,9 @@ module.exports = {
{
devDependencies: [
'.storybook/**',
'scripts/seedManuscripts.js',
'scripts/seedUsers.js',
'server/models/__tests__/**',
'ui/stories/**',
'webpack/webpack.development.config.js',
],
......
......@@ -3,11 +3,11 @@ config/local*
config/*.env
coverage
node_modules
uploads
uploads/*
!uploads/sample-image.jpg
logs
.env
.envrc
.env*
npm-debug.log
package-lock.json
yarn-error.log
/* eslint-disable react/prop-types */
import React from 'react'
import styled from 'styled-components'
import { clone } from 'lodash'
import ReactTable from 'react-table'
import 'react-table/react-table.css'
import { Accordion, Action, Button, List } from '@pubsweet/ui'
import { th } from '@pubsweet/ui-toolkit'
import ComposedAssignReviewers from './compose/AssignReviewers'
import { PageHeader, Select as DefaultSelect } from './ui'
import { TextField } from './formElements'
import { AddReviewerForm, AssignReviewersForm } from './form'
import Loading from './Loading'
const Centered = styled.div`
margin: 0 auto;
max-width: 1024px;
/* width: 60%; */
> div {
margin-bottom: calc(${th('gridUnit')} * 2);
}
`
const StyledPageHeader = styled(PageHeader)`
margin-top: 0;
`
const Invite = styled(Action)`
line-height: unset;
`
const Table = styled(ReactTable)`
width: 100%;
.rt-tbody {
text-align: center;
}
.rt-th {
&:focus {
outline: none;
}
}
div.rt-noData {
display: none;
}
`
const ContentWrapper = styled.div`
cursor: default;
display: flex;
flex-direction: column;
margin-left: ${th('gridUnit')};
form {
width: 100%;
button {
margin-top: calc(${th('gridUnit')} * 2);
}
}
`
const Select = styled(DefaultSelect)`
max-width: unset !important;
width: 100%;
`
const Tag = styled.span`
background-color: ${th('colorBackgroundHue')};
font-size: ${th('fontSizeBase')};
line-height: ${th('lineHeightBase')};
margin: ${th('gridUnit')} 0;
padding: calc(${th('gridUnit')} / 2);
`
const InviteSectionWrapper = styled.div`
margin: calc(${th('gridUnit')} * 3) 0;
`
const InviteSection = props => {
const { addExternalReviewer } = props
return (
<InviteSectionWrapper>
<Action>Add a reviewer that is not in the system</Action>
<AddReviewerForm addExternalReviewer={addExternalReviewer}>
{formProps => {
const { errors, values, ...rest } = formProps
return (
<React.Fragment>
<TextField
error={errors.givenNames}
label="Given names"
name="givenNames"
required
value={values.givenNames}
{...rest}
/>
<TextField
error={errors.surname}
label="Surname"
name="surname"
required
value={values.surname}
{...rest}
/>
<TextField
error={errors.email}
label="Email"
name="email"
required
value={values.email}
{...rest}
/>
<Button primary type="submit">
OK
</Button>
</React.Fragment>
)
}}
</AddReviewerForm>
</InviteSectionWrapper>
)
}
const SuggestedReviewer = props => {
const { name } = props
return <Tag>{name}</Tag>
}
const SuggestedReviewers = props => {
const { data } = props
if (!data || data.length === 0) return null
const items = data.map(item => {
const i = clone(item)
i.id = i.WBId
return i
})
return <List component={SuggestedReviewer} items={items} />
}
const Section = ({ children, label }) => (
<Accordion label={label} startExpanded>
<ContentWrapper>{children}</ContentWrapper>
</Accordion>
)
const EmptyMessage = styled.div`
color: ${th('colorTextPlaceholder')};
margin: 0 auto;
`
const LinkWrapper = styled.div`
margin-bottom: ${th('gridUnit')};
`
const isMember = (members, userId) => members.find(m => m.user.id === userId)
const ReviewerTable = props => {
const {
acceptedTeam,
articleId,
data,
inviteReviewer,
invitedTeam,
rejectedTeam,
} = props
const modifiedData = data.map(i => {
const reviewer = clone(i.user)
reviewer.name = reviewer.displayName
return reviewer
})
const rows = modifiedData.map(reviewer => {
const item = clone(reviewer)
item.status = 'Not invited'
item.action = 'Invite'
if (isMember(invitedTeam, item.id)) {
item.status = 'Invited'
item.action = 'Re-send invitation'
}
if (isMember(rejectedTeam, item.id)) {
item.status = 'Rejected'
item.action = '-'
} else if (isMember(acceptedTeam, item.id)) {
item.status = 'Accepted'
item.action = '-'
}
return item
})
const sendInvitation = (aritcleId, reviewer) => {
const { id: reviewerId } = reviewer
inviteReviewer(reviewerId)
}
const columns = [
{
accessor: 'name',
Header: 'Name',
},
{
accessor: 'email',
Header: 'Email',
/* eslint-disable-next-line sort-keys */
Cell: context => (
<a href={`mailto:${context.original.email}`}>
{context.original.email}
</a>
),
},
{
accessor: 'status',
Header: 'Status',
},
{
accessor: 'agreedTc',
Header: 'Signed up',
/* eslint-disable-next-line sort-keys */
Cell: context => {
const text = context.original.agreedTc ? 'Yes' : 'No'
return <span>{text}</span>
},
},
{
accessor: 'action',
Header: 'Action',
/* eslint-disable-next-line sort-keys */
Cell: context => {
const { original, value } = context
if (value === '-') return value
return (
<Invite onClick={() => sendInvitation(articleId, original)}>
{value}
</Invite>
)
},
},
]
return (
<React.Fragment>
{(!modifiedData || modifiedData.length === 0) && (
<EmptyMessage>There are currently no reviewers</EmptyMessage>
)}
{modifiedData && modifiedData.length > 0 && (
<Table
className="-striped -highlight"
columns={columns}
data={rows}
minRows={0}
resizable={false}
showPagination={false}
/>
)}
</React.Fragment>
)
}
const AssignReviewers = props => {
const {
addExternalReviewer,
articleId,
inviteReviewer,
loading,
suggested,
reviewersTeam,
users,
updateReviewerPool,
...otherProps
} = props
if (loading) return <Loading />
let suggestedReviewers
if (suggested && suggested.name && suggested.name.length > 0)
suggestedReviewers = [suggested]
const invitedTeam = reviewersTeam.members.filter(t => t.status === 'invited')
const acceptedTeam = reviewersTeam.members.filter(
t => t.status === 'acceptedInvitation',
)
const rejectedTeam = reviewersTeam.members.filter(
t => t.status === 'rejectedInvitation',
)
const userOptions = users
? users.map(user => ({
label: user.displayName,
value: user.id,
}))
: []
const reviewers = reviewersTeam.members
return (
<Centered>
<StyledPageHeader>Assign Reviewers</StyledPageHeader>
<LinkWrapper>
<Action to={`/article/${articleId}`}>Back to Article</Action>
</LinkWrapper>
<Section label="Status">
<ReviewerTable
acceptedTeam={acceptedTeam}
articleId={articleId}
data={reviewers}
invitedTeam={invitedTeam}
inviteReviewer={inviteReviewer}
rejectedTeam={rejectedTeam}
/>
</Section>
<Section label="Suggested Reviewer by the Author">
<SuggestedReviewers data={suggestedReviewers} />
</Section>
<Section label="Assign Reviewers to Article">
<AssignReviewersForm
articleId={articleId}
reviewers={reviewers}
updateReviewerPool={updateReviewerPool}
{...otherProps}
>
{formProps => {
const { dirty, setFieldValue, values } = formProps
const handleChange = newValue =>
setFieldValue('reviewers', newValue)
return (
<React.Fragment>
<Select
closeMenuOnSelect={false}
isMulti
name="reviewers"
onChange={handleChange}
options={userOptions}
value={values.reviewers}
/>
<Button disabled={!dirty} primary type="submit">
Save
</Button>
</React.Fragment>
)
}}
</AssignReviewersForm>
<InviteSection addExternalReviewer={addExternalReviewer} />
</Section>
</Centered>
)
}
const Composed = () => <ComposedAssignReviewers render={AssignReviewers} />
export default Composed
......@@ -27,6 +27,15 @@ const GET_MANUSCRIPT_STATUS_FOR_NAVIGATION = gql`
}
`
// const Beta = styled.span`
// background: ${th('colorPrimary')};
// border-radius: 3px;
// color: ${th('colorTextReverse')};
// font-size: 12px;
// padding: 2px 4px;
// text-transform: uppercase;
// `
const Section = styled.div`
align-items: center;
display: flex;
......@@ -59,11 +68,11 @@ const StyledBar = styled(AppBar)`
const navLinks = (location, currentUser) => {
const isDashboard = location.pathname.match(/dashboard/g)
const isArticle = location.pathname.match(/article/g)
const isReviewers = location.pathname.match(/assign-reviewers/g)
const isReviewers = location.pathname.match(/assign-reviewers\//g)
const isTeamManager = location.pathname.match(/teams/g)
const isUserManager = location.pathname.match(/users/g)
const path = `/${isArticle ? 'article' : 'assign-reviewers'}/:id`
const path = `/(article|assign-reviewers|assign-reviewers-new)/:id`
const match = matchPath(location.pathname, { path })
let id
if (match) ({ id } = match.params)
......
......@@ -10,7 +10,9 @@ const GlobalStyle = createGlobalStyle`
}
body {
font-family: ${props => props.theme.fontInterface};
height: 100vh;
line-height: ${props => props.theme.lineHeightBase};
overflow: hidden;
}
......
/* eslint-disable react/prop-types */
import React from 'react'
import { adopt } from 'react-adopt'
import { withRouter } from 'react-router-dom'
import { get, last } from 'lodash'
import {
// queries
getArticleReviewers,
getGlobalTeams,
getUsers,
// mutations
addExternalReviewer,
inviteReviewer,
updateReviewerPool,
} from './pieces'
import { getRegularUsers } from '../../helpers/teams'
/* eslint-disable sort-keys */
const mapper = {
// queries
getArticleReviewers: props => getArticleReviewers(props),
getGlobalTeams,
getUsers,
// mutations
addExternalReviewer,
inviteReviewer,
updateReviewerPool,
}
/* eslint-enable sort-keys */
// eslint-disable-next-line arrow-body-style
const mapProps = args => {
const data = get(args.getArticleReviewers, 'data.manuscript')
let reviewersTeam, suggested, version
if (data) {
version = last(data.versions.filter(v => v.submitted))
reviewersTeam = version.teams.find(t => t.role === 'reviewer')
suggested = version.suggestedReviewer
}
const addExternalReviewerFn = input =>
args.addExternalReviewer.addExternalReviewer({
variables: {
input,
manuscriptVersionId: version.id,
},
})
const inviteReviewerFn = reviewerId =>
args.inviteReviewer.inviteReviewer({
variables: {
input: { reviewerId },
manuscriptVersionId: version.id,
},
})
const updateReviewerPoolFn = input =>
args.updateReviewerPool.updateReviewerPool({
variables: {
input,
manuscriptVersionId: version.id,
},
})
return {
addExternalReviewer: addExternalReviewerFn,
inviteReviewer: inviteReviewerFn,
loading:
args.getArticleReviewers.loading ||
args.getUsers.loading ||
args.getGlobalTeams.loading,
reviewersTeam,
suggested,
updateReviewerPool: updateReviewerPoolFn,
users: getRegularUsers(
get(args.getUsers, 'data.users'),
get(args.getGlobalTeams, 'data.globalTeams'),
),
}
}
const Composed = adopt(mapper, mapProps)
const ComposedAssignReviewers = props => {
const { match, render } = props
const { id: articleId } = match.params
return (
<Composed articleId={articleId}>
{mappedProps => render({ ...mappedProps, articleId })}
</Composed>
)
}
export default withRouter(ComposedAssignReviewers)
/* eslint-disable react/prop-types */
import React from 'react'
import { Mutation } from '@apollo/react-components'
import gql from 'graphql-tag'
import { GET_ARTICLE_REVIEWERS } from './getArticleReviewers'
import { withCurrentUser } from '../../../userContext'
const ADD_EXTERNAL_REVIEWER = gql`
mutation AddExternalReviewer(
$manuscriptVersionId: ID!
$input: AddExternalReviewerInput!
) {
addExternalReviewer(
manuscriptVersionId: $manuscriptVersionId
input: $input
)
}
`
const AddExternalReviewerMutation = props => {
const { articleId, render } = props
const refetchQueries = [
{
query: GET_ARTICLE_REVIEWERS,
variables: { id: articleId },
},
]
return (
<Mutation mutation={ADD_EXTERNAL_REVIEWER} refetchQueries={refetchQueries}>
{(addExternalReviewer, addExternalReviewerResponse) =>
render({ addExternalReviewer, addExternalReviewerResponse })
}
</Mutation>
)
}
// TO DO -- withCurrentUser not needed
export default withCurrentUser(AddExternalReviewerMutation)
/* eslint-disable react/prop-types */
import React from 'react'
import { Query } from '@apollo/react-components'
import gql from 'graphql-tag'
const GET_ARTICLE_REVIEWERS = gql`
query GetArticleReviewers($id: ID!) {