diff --git a/app/components/submission/ReviewerSuggestions/FormSections.js b/app/components/submission/ReviewerSuggestions/FormSections.js new file mode 100644 index 0000000000000000000000000000000000000000..a3fb1776df14d28c48278520e772649f170b9d2c --- /dev/null +++ b/app/components/submission/ReviewerSuggestions/FormSections.js @@ -0,0 +1,128 @@ +import React from 'react' +import { Flex, Box } from 'grid-styled' +import { Checkbox } from '@pubsweet/ui' + +import ValidatedField from '../../ui/atoms/ValidatedField' +import CalloutBox from '../../ui/atoms/CalloutBox' +import Textarea from '../../ui/atoms/Textarea' + +export const SuggestedSeniorEditorRow = ({ rowIndex }) => ( + <Flex> + <Box width={1 / 2}> + <ValidatedField + label="Suggested senior editor" + name={`suggestedSeniorEditors.${rowIndex}`} + /> + </Box> + <Box width={1 / 2}> + <ValidatedField + label="Suggested senior editor" + name={`suggestedSeniorEditors.${rowIndex + 1}`} + /> + </Box> + </Flex> +) + +export const ExcludedSeniorEditor = ({ index }) => ( + <CalloutBox> + <ValidatedField + label="Excluded senior editor" + name={`opposedSeniorEditors.${index}.name`} + /> + <ValidatedField + component={Textarea} + label="Reason for exclusion" + name={`opposedSeniorEditors.${index}.reason`} + /> + </CalloutBox> +) + +export const SuggestedReviewingEditorRow = ({ rowIndex }) => ( + <Flex> + <Box width={1 / 2}> + <ValidatedField + label="Suggested reviewing editor" + name={`suggestedReviewingEditors.${rowIndex}`} + /> + </Box> + <Box width={1 / 2}> + <ValidatedField + label="Suggested reviewing editor" + name={`suggestedReviewingEditors.${rowIndex + 1}`} + /> + </Box> + </Flex> +) + +export const ExcludedReviewingEditor = ({ index }) => ( + <CalloutBox> + <ValidatedField + label="Excluded reviewing editor" + name={`opposedReviewingEditors.${index}.name`} + /> + <ValidatedField + component={Textarea} + label="Reason for exclusion" + name={`opposedSeniorEditors.${index}.reason`} + /> + </CalloutBox> +) + +export const SuggestedReviewer = ({ index }) => ( + <Flex> + <Box width={1 / 2}> + <ValidatedField + label="Suggested reviewer name" + name={`suggestedReviewers.${index}.name`} + /> + </Box> + <Box width={1 / 2}> + <ValidatedField + label="Suggested reviewer email" + name={`suggestedReviewers.${index}.email`} + type="email" + /> + </Box> + </Flex> +) +export const ExcludedReviewer = ({ index }) => ( + <CalloutBox> + <Flex> + <Box width={1 / 2}> + <ValidatedField + label="Excluded reviewer name" + name={`opposedReviewers.${index}.name`} + /> + </Box> + <Box width={1 / 2}> + <ValidatedField + label="Excluded reviewer email" + name={`opposedReviewers.${index}.email`} + type="email" + /> + </Box> + </Flex> + <ValidatedField + component={Textarea} + label="Reason for exclusion" + name={`opposedReviewers.${index}.reason`} + /> + </CalloutBox> +) + +// pass `value` prop to `checked` +const ValueCheckbox = ({ value, validationStatus, ...props }) => ( + <Box mb={3}> + <Checkbox checked={value} {...props} /> + </Box> +) + +export const Declaration = () => ( + <Box mb={3}> + <ValidatedField + component={ValueCheckbox} + label="I declare that, to the best of my knowledge, these experts have no conflict of interest" + name="declaration" + /> + </Box> +) diff --git a/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.js b/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.js index e2e9cfaff4b8b9e409506c7a5ef6101ea74630ee..6685a3eb5f9f0988882effca8b0de9dae1675ec2 100644 --- a/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.js +++ b/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.js @@ -1,50 +1,136 @@ import React from 'react' -import { Flex, Box } from 'grid-styled' -import { Button, H1 } from '@pubsweet/ui' +import { Box } from 'grid-styled' +import { Button, H1, PlainButton } from '@pubsweet/ui' -import ValidatedField from '../../ui/atoms/ValidatedField' import ButtonLink from '../../ui/atoms/ButtonLink' import ProgressBar from '../ProgressBar' +import { + Declaration, + ExcludedReviewer, + ExcludedReviewingEditor, + ExcludedSeniorEditor, + SuggestedReviewer, + SuggestedReviewingEditorRow, + SuggestedSeniorEditorRow, +} from './FormSections' -const ReviewerSuggestions = ({ handleSubmit }) => ( +const MoreButton = ({ + empty, + fieldName, + roleName, + setFieldValue, + type = 'suggest', + more = 'another', + values, +}) => ( + <PlainButton + onClick={() => + setFieldValue(fieldName, values[fieldName].concat(empty), false) + } + type="button" + > + {type} {values[fieldName].length ? more : 'a'} {roleName} + </PlainButton> +) + +const MAX_EXCLUDED_EDITORS = 2 + +const ReviewerSuggestions = ({ + handleSubmit, + values, + setValues, + setFieldValue, +}) => ( <form noValidate onSubmit={handleSubmit}> <ProgressBar currentStep={3} /> <H1>Who would you like to review your work?</H1> - <ValidatedField label="Suggested editor(s)" name="suggestedEditors" /> - <ValidatedField label="Excluded editor(s)" name="excludedEditors" /> - <Flex> - <Box width={1 / 2}> - <ValidatedField - label="Suggested reviewer name" - name="suggestedReviewerName" - /> - </Box> - <Box width={1 / 2}> - <ValidatedField - label="Suggested reviewer email" - name="suggestedReviewerEmail" - type="email" - /> - </Box> - </Flex> - - <Flex> - <Box width={1 / 2}> - <ValidatedField - label="Excluded reviewer name" - name="excludedReviewerName" - /> - </Box> - <Box width={1 / 2}> - <ValidatedField - label="Excluded reviewer email" - name="exludedReviewerEmail" - type="email" - /> + <SuggestedSeniorEditorRow rowIndex={0} /> + + {values.opposedSeniorEditors.map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + <ExcludedSeniorEditor index={index} key={index} /> + ))} + + {values.opposedSeniorEditors.length < MAX_EXCLUDED_EDITORS && ( + <Box my={3}> + Would you like to{' '} + <MoreButton + empty={{ name: '', reason: '' }} + fieldName="opposedSeniorEditors" + roleName="senior editor" + setFieldValue={setFieldValue} + type="exclude" + values={values} + />? </Box> - </Flex> + )} + + {values.suggestedReviewingEditors + .filter((_, index) => index % 2) + .map((_, rowIndex) => ( + // eslint-disable-next-line react/no-array-index-key + <SuggestedReviewingEditorRow key={rowIndex} rowIndex={rowIndex} /> + ))} + + {values.opposedReviewingEditors.map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + <ExcludedReviewingEditor index={index} key={index} /> + ))} + + <Box my={3}> + Would you like to{' '} + <MoreButton + empty={['', '']} + fieldName="suggestedReviewingEditors" + more="more" + roleName="reviewing editors" + setFieldValue={setFieldValue} + values={values} + />{' '} + or{' '} + <MoreButton + empty={{ name: '', reason: '' }} + fieldName="opposedReviewingEditors" + roleName="reviewing editor" + setFieldValue={setFieldValue} + type="exclude" + values={values} + />? + </Box> + + {values.suggestedReviewers.map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + <SuggestedReviewer index={index} key={index} /> + ))} + + {values.opposedReviewers.map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + <ExcludedReviewer index={index} key={index} /> + ))} + + <Box my={3}> + Would you like to{' '} + <MoreButton + empty="" + fieldName="suggestedReviewers" + roleName="reviewer" + setFieldValue={setFieldValue} + values={values} + />{' '} + or{' '} + <MoreButton + empty={{ name: '', email: '', reason: '' }} + fieldName="opposedReviewers" + roleName="reviewer" + setFieldValue={setFieldValue} + type="exclude" + values={values} + />? + </Box> + + <Declaration /> <Button primary type="submit"> Next diff --git a/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.test.js b/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.test.js new file mode 100644 index 0000000000000000000000000000000000000000..7aadc881d15c3bc2b3deb1ea9d5884ca84a43eef --- /dev/null +++ b/app/components/submission/ReviewerSuggestions/ReviewerSuggestions.test.js @@ -0,0 +1,46 @@ +import React from 'react' +import { mount } from 'enzyme' +import { Formik } from 'formik' +import { MemoryRouter } from 'react-router-dom' + +import ReviewerSuggestions from './ReviewerSuggestions' +import { empty, schema } from './ReviewerSuggestionsSchema' + +function makeWrapper(props) { + return mount( + <MemoryRouter> + <Formik + component={ReviewerSuggestions} + initialValues={empty} + onSubmit={jest.fn()} + validationSchema={schema} + {...props} + /> + </MemoryRouter>, + ) +} + +function countInputsMatching(wrapper, regex) { + return wrapper + .find('input') + .filterWhere(node => node.prop('name').match(regex)).length +} + +describe('ReviewerSuggestions component', () => { + it('renders default form', () => { + const wrapper = makeWrapper() + expect(countInputsMatching(wrapper, /suggestedSeniorEditor/)).toBe(2) + expect(countInputsMatching(wrapper, /suggestedReviewingEditor/)).toBe(2) + expect(countInputsMatching(wrapper, /suggestedReviewer.+name/)).toBe(3) + }) + + it('adds excluded senior editor', () => { + const wrapper = makeWrapper() + expect(countInputsMatching(wrapper, /opposedSeniorEditor/)).toBe(0) + wrapper + .find('MoreButton') + .at(0) + .simulate('click') + expect(countInputsMatching(wrapper, /opposedSeniorEditor/)).toBe(1) + }) +}) diff --git a/app/components/submission/ReviewerSuggestions/ReviewerSuggestionsSchema.js b/app/components/submission/ReviewerSuggestions/ReviewerSuggestionsSchema.js index 8c69f8bebc1188e908a95087bbf27c4308f94c17..b0556ad60707471fe199039614cabdc68cf097e0 100644 --- a/app/components/submission/ReviewerSuggestions/ReviewerSuggestionsSchema.js +++ b/app/components/submission/ReviewerSuggestions/ReviewerSuggestionsSchema.js @@ -1,19 +1,70 @@ import yup from 'yup' -const email = () => yup.string().email('Must be a valid email address') +// TODO only the initially displayed fields should be required, +// fields added by the user should be optional + +const suggestedEditorValidator = () => + yup.array(yup.string().required('Required')) + +const opposedEditorValidator = () => + yup.array( + yup.object({ + name: yup.string().required('Required'), + reason: yup.string().required('Required'), + }), + ) + +const suggestedReviewerValidator = () => + yup.array( + yup.object({ + name: yup.string().required('Name is required'), + email: yup + .string() + .email('Must be a valid email') + .required('Email is required'), + }), + ) + +const opposedReviewerValidator = () => + yup.array( + yup.object({ + name: yup.string().required('Name is required'), + email: yup + .string() + .email('Must be a valid email') + .required('Email is required'), + reason: yup.string().required('Required'), + }), + ) const schema = yup.object().shape({ - suggestedReviewerEmail: email(), - excludedReviewerEmail: email(), + suggestedSeniorEditors: suggestedEditorValidator(), + opposedSeniorEditors: opposedEditorValidator(), + suggestedReviewingEditors: suggestedEditorValidator(), + opposedReviewingEditors: opposedEditorValidator(), + suggestedReviewers: suggestedReviewerValidator(), + opposedReviewers: opposedReviewerValidator(), + declaration: yup + .bool() + .required() + .oneOf( + [true], + 'Please do not suggest people with a known confilct of interest', + ), }) const empty = { - suggestedEditors: '', - excludedEditors: '', - suggestedReviewerName: '', - excludedReviewerName: '', - suggestedReviewerEmail: '', - excludedReviewerEmail: '', + suggestedSeniorEditors: ['', ''], + opposedSeniorEditors: [], + suggestedReviewingEditors: ['', ''], + opposedReviewingEditors: [], + suggestedReviewers: [ + { name: '', email: '' }, + { name: '', email: '' }, + { name: '', email: '' }, + ], + opposedReviewers: [], + declaration: false, } export { schema, empty } diff --git a/app/components/ui/atoms/CalloutBox.js b/app/components/ui/atoms/CalloutBox.js new file mode 100644 index 0000000000000000000000000000000000000000..00b36f13c455a6481d4b9a9c986ce7fd5e09510c --- /dev/null +++ b/app/components/ui/atoms/CalloutBox.js @@ -0,0 +1,10 @@ +import styled from 'styled-components' +import { Box } from 'grid-styled' +import { th } from '@pubsweet/ui' + +const CalloutBox = styled(Box).attrs({ mx: -3, px: 3, mb: 3 })` + border: ${th('borderWidth')} ${th('borderStyle')} ${th('borderColor')}; + border-radius: ${th('borderRadius')}; +` + +export default CalloutBox diff --git a/app/components/ui/atoms/Textarea.js b/app/components/ui/atoms/Textarea.js new file mode 100644 index 0000000000000000000000000000000000000000..4a7f8035f3d78f8fad46bbd362a960ad81445416 --- /dev/null +++ b/app/components/ui/atoms/Textarea.js @@ -0,0 +1,58 @@ +import React from 'react' +import styled from 'styled-components' +import { th } from '@pubsweet/ui' + +const Root = styled.div` + display: flex; + flex-direction: column; + max-width: calc(${th('gridUnit')} * 14); + margin-bottom: ${th('gridUnit')}; +` + +const Label = styled.label` + font-size: ${th('fontSizeBaseSmall')}; + display: block; +` + +const borderColor = ({ theme, validationStatus = 'default' }) => + ({ + error: theme.colorError, + success: theme.colorSuccess, + warning: theme.colorWarning, + default: theme.colorBorder, + }[validationStatus]) + +const Input = styled.textarea` + border: ${th('borderWidth')} ${th('borderStyle')} ${borderColor}; + + border-radius: ${th('borderRadius')}; + + font-family: inherit; + font-size: inherit; + + padding: calc(${th('gridUnit')} / 2); + min-height: calc(${th('fontLineHeight')} * 2); + + &::placeholder { + color: ${th('colorTextPlaceholder')}; + } +` + +class Textarea extends React.Component { + componentWillMount() { + // generate a unique ID to link the label to the input + // note this may not play well with server rendering + this.inputId = `textarea-${Math.round(Math.random() * 1e12).toString(36)}` + } + render() { + const { label, value = '', readonly, ...props } = this.props + return ( + <Root> + {label && <Label htmlFor={this.inputId}>{label}</Label>} + <Input id={this.inputId} readOnly={readonly} value={value} {...props} /> + </Root> + ) + } +} + +export default Textarea diff --git a/app/components/ui/atoms/ValidatedField.js b/app/components/ui/atoms/ValidatedField.js index a1180c48f7105506f96f5f71f17e075394db9ce6..797c6cc9d2bdced16f4d09c92228da7422e41fe8 100644 --- a/app/components/ui/atoms/ValidatedField.js +++ b/app/components/ui/atoms/ValidatedField.js @@ -15,7 +15,11 @@ const ErrorMessage = styled.div` color: ${th('colorError')}; ` -export default ({ name, component: FieldComponent = TextField, ...props }) => { +const ValidatedField = ({ + name, + component: FieldComponent = TextField, + ...props +}) => { const render = ({ field, form }) => { const touched = get(form.touched, name) const errors = get(form.errors, name) @@ -42,3 +46,5 @@ export default ({ name, component: FieldComponent = TextField, ...props }) => { return <Field name={name} render={render} /> } + +export default ValidatedField