diff --git a/packages/components/Login/Login.jsx b/packages/components/Login/Login.jsx index 6cc5a2d8597fa53e053e4c5615f2502093e17280..00a613b9690eb79101abf0c81b3144c6b73876a8 100644 --- a/packages/components/Login/Login.jsx +++ b/packages/components/Login/Login.jsx @@ -1,4 +1,5 @@ import React from 'react' +import { Redirect } from 'react-router' import PropTypes from 'prop-types' import { Field } from 'formik' import { isEmpty } from 'lodash' @@ -26,34 +27,38 @@ const Login = ({ handleSubmit, signup = true, passwordReset = true, -}) => ( - <CenteredColumn small> - <H1>Login</H1> + redirectLink, +}) => + redirectLink ? ( + <Redirect to={redirectLink} /> + ) : ( + <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> + {!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> - )} + {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> -) + {passwordReset && ( + <ResetPassword> + <span>Forgot your password? </span> + <Link to="/password-reset">Reset password</Link> + </ResetPassword> + )} + </CenteredColumn> + ) Login.propTypes = { error: PropTypes.string, diff --git a/packages/components/Login/graphql/LoginContainer.js b/packages/components/Login/graphql/LoginContainer.js index 83715d8019cbd8eb24bf086d9b7ebdb733daf80e..766c9d31812f69ef3697188580e115460b6735d9 100644 --- a/packages/components/Login/graphql/LoginContainer.js +++ b/packages/components/Login/graphql/LoginContainer.js @@ -1,25 +1,29 @@ -import { compose } from 'recompose' +import { compose, withState, withHandlers } from 'recompose' import { withFormik } from 'formik' import { graphql } from 'react-apollo' - import mutations from './mutations' import Login from '../Login' -import redirectPath from '../redirect' + +const getNextUrl = () => { + const url = new URL(window.location.href) + return `${url.searchParams.get('next') || '/'}` +} 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) - } + .loginUser({ + variables: { input: values }, + }) + .then(({ data }) => { + localStorage.setItem('token', data.loginUser.token) + setTimeout(() => { + props.onLoggedIn(getNextUrl()) + }, 100) }) .catch(e => { - if (e.graphQLErrors) { + if (e.graphQLErrors.length > 0) { setSubmitting(false) setErrors(e.graphQLErrors[0].message) } @@ -38,6 +42,12 @@ const enhancedFormik = withFormik({ handleSubmit, })(Login) -export default compose(graphql(mutations.LOGIN_USER, { name: 'loginUser' }))( - enhancedFormik, -) +export default compose( + graphql(mutations.LOGIN_USER, { + name: 'loginUser', + }), + withState('redirectLink', 'loggedIn', null), + withHandlers({ + onLoggedIn: ({ loggedIn }) => returnUrl => loggedIn(() => returnUrl), + }), +)(enhancedFormik) diff --git a/packages/components/Login/graphql/LoginContainer.test.jsx b/packages/components/Login/graphql/LoginContainer.test.jsx index 7df5518babca59528ec37fa596338e687f192365..9ce5c596edaac8f69e5bbdad6865a76b41788c47 100644 --- a/packages/components/Login/graphql/LoginContainer.test.jsx +++ b/packages/components/Login/graphql/LoginContainer.test.jsx @@ -2,7 +2,7 @@ import React from 'react' import { mount } from 'enzyme' import { MockedProvider } from 'react-apollo/test-utils' import { ThemeProvider } from 'styled-components' -import { MemoryRouter, Route } from 'react-router-dom' +import { MemoryRouter, Route, Switch } from 'react-router-dom' import wait from 'waait' import { LOGIN_USER } from './mutations' @@ -66,14 +66,25 @@ function makeDeepWrapper(currentUser, props = {}) { return mount( <ThemeProvider theme={theme}> <MockedProvider addTypename={false} mocks={mocks(currentUser)}> - <MemoryRouter initialEntries={['/login']}> - <Route - {...props} - render={p => { - globalLocation = p.location - return <LoginContainer {...p} /> - }} - /> + <MemoryRouter initialEntries={['/login', '/']}> + <Switch> + <Route + {...props} + path="/login" + render={p => { + globalLocation = p.location + return <LoginContainer {...p} /> + }} + /> + <Route + {...props} + path="/" + render={p => { + globalLocation = p.location + return <p>dashboard</p> + }} + /> + </Switch> </MemoryRouter> </MockedProvider> </ThemeProvider>, @@ -109,11 +120,11 @@ describe('LoginContainer', () => { const button = wrapper.find('button') button.simulate('submit') + await wait(500) wrapper.update() - await wait(50) expect(window.localStorage.token).toEqual('greatToken') - expect(globalLocation.pathname).toEqual('/testRedirect') + expect(globalLocation.pathname).toEqual('/') }) it('does not log in user with incorrect credentials', async () => { diff --git a/packages/components/Login/package.json b/packages/components/Login/package.json index 31818c0dd5651fb81e4f6710e840bc28b7fda560..632b21454b17713773fa8e0852409e4c6f0fc24c 100644 --- a/packages/components/Login/package.json +++ b/packages/components/Login/package.json @@ -21,6 +21,7 @@ "pubsweet-client": ">=1.0.0", "react": ">=15", "react-apollo": "^2.3.3", + "react-router": "^4.3.1", "styled-components": "^4.1.3" }, "repository": { diff --git a/packages/components/xpub-dashboard/src/components/DashboardPage.js b/packages/components/xpub-dashboard/src/components/DashboardPage.js index bd9f146339a7ce347b02d6c0437d6cd0a63e81d4..85c73cd177936af5860ea636489038a388c24564 100644 --- a/packages/components/xpub-dashboard/src/components/DashboardPage.js +++ b/packages/components/xpub-dashboard/src/components/DashboardPage.js @@ -15,16 +15,39 @@ const acceptFiles = ? acceptUploadFiles.join() : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const updateReviewer = (proxy, { data: { reviewerResponse } }) => { + const id = reviewerResponse.object.objectId + const data = proxy.readQuery({ + query: queries.dashboard, + variables: { + id, + }, + }) + + const manuscriptIndex = data.journals.manuscripts.findIndex( + manu => manu.id === id, + ) + const teamIndex = data.journals.manuscripts[manuscriptIndex].teams.findIndex( + team => team.id === reviewerResponse.id, + ) + + data.journals.manuscripts[manuscriptIndex].teams[teamIndex] = reviewerResponse + + proxy.writeQuery({ query: queries.dashboard, data }) +} + export default compose( connectToContext(), graphql(queries.dashboard, { - options: { context: { online: false } }, props: data => data, }), graphql(mutations.reviewerResponseMutation, { props: ({ mutate }) => ({ - reviewerResponse: (manuscript, response) => - mutate({ variables: { id: manuscript.id, response } }), + reviewerResponse: (currentUserId, action, teamId) => + mutate({ + variables: { currentUserId, action, teamId }, + update: updateReviewer, + }), }), }), graphql(mutations.deleteManuscriptMutation, { @@ -36,7 +59,7 @@ export default compose( update: (proxy, { data: { deleteManuscript } }) => { const data = proxy.readQuery({ query: queries.dashboard }) const manuscriptIndex = data.journals.manuscripts.findIndex( - manuscript => manuscript.id === deleteManuscript.id, + manuscript => manuscript.id === deleteManuscript, ) if (manuscriptIndex > -1) { data.journals.manuscripts.splice(manuscriptIndex, 1) diff --git a/packages/components/xpub-dashboard/src/components/sections/EditorItem.test.js b/packages/components/xpub-dashboard/src/components/sections/EditorItem.test.js index c1bb80977252039b0ca7a5756429b4bfd591ff8a..6b6d90e95ac8bf482c213f50426724c1cd146ad7 100644 --- a/packages/components/xpub-dashboard/src/components/sections/EditorItem.test.js +++ b/packages/components/xpub-dashboard/src/components/sections/EditorItem.test.js @@ -125,7 +125,7 @@ describe('EditorItem', () => { }, }, ], - role: 'author', + teamType: 'author', }, ], meta: { diff --git a/packages/components/xpub-dashboard/src/components/sections/OwnerItem.js b/packages/components/xpub-dashboard/src/components/sections/OwnerItem.js index 33b2896c239dd031dd1cf62b0933b25a63d7006a..96470f3ee1ab1c4e26d9e3728ed97a95f332e063 100644 --- a/packages/components/xpub-dashboard/src/components/sections/OwnerItem.js +++ b/packages/components/xpub-dashboard/src/components/sections/OwnerItem.js @@ -46,7 +46,7 @@ const OwnerItem = ({ version, journals, deleteManuscript }) => { const actions = ( <AuthorizeWithGraphQL object={version} - operation="can delete collection" + operation="can delete manuscript" unauthorized={unauthorized} > <ActionGroup>{Object.values(actionButtons)}</ActionGroup> diff --git a/packages/components/xpub-dashboard/src/components/sections/ReviewerItem.js b/packages/components/xpub-dashboard/src/components/sections/ReviewerItem.js index 2422ea598ca0a442c5f810239dc17969d0dea14a..8cc01ee47b16f628483e3021e381b994234d4ef3 100644 --- a/packages/components/xpub-dashboard/src/components/sections/ReviewerItem.js +++ b/packages/components/xpub-dashboard/src/components/sections/ReviewerItem.js @@ -1,7 +1,8 @@ import React from 'react' import { Button } from '@pubsweet/ui' import AuthorizeWithGraphQL from 'pubsweet-client/src/helpers/AuthorizeWithGraphQL' -import { getUserFromTeam } from 'xpub-selectors' +// Enable that when Team Models is updated +// import { getUserFromTeam } from 'xpub-selectors' import { Item, Body, Divider } from '../molecules/Item' import { Links, LinkContainer } from '../molecules/Links' import { Actions, ActionContainer } from '../molecules/Actions' @@ -14,12 +15,22 @@ import VersionTitle from './VersionTitle' // TODO: review id in link const ReviewerItem = ({ version, journals, currentUser, reviewerResponse }) => { + const team = + (version.teams || []).find(team => team.teamType === 'reviewerEditor') || {} const { status } = - getUserFromTeam(version, 'reviewerEditor').filter( - member => member.user.id === currentUser.id, - )[0] || {} + (team.status || []).filter(member => member.user === currentUser.id)[0] || + {} - const review = version.reviews[0] || {} + // Enable that when Team Models is updated + // const { status } = + // getUserFromTeam(version, 'reviewerEditor').filter( + // member => member.id === currentUser.id, + // )[0] || {} + + const review = + (version.reviews || []).find( + review => review.user.id === currentUser.id && !review.isDecision, + ) || {} return ( <AuthorizeWithGraphQL @@ -31,7 +42,7 @@ const ReviewerItem = ({ version, journals, currentUser, reviewerResponse }) => { <Body> <VersionTitle version={version} /> - {status === 'accepted' && ( + {(status === 'accepted' || status === 'completed') && ( <Links> <LinkContainer> <JournalLink @@ -40,7 +51,7 @@ const ReviewerItem = ({ version, journals, currentUser, reviewerResponse }) => { page="reviews" version={version} > - {review.recommendation ? 'Completed' : 'Do Review'} + {status === 'completed' ? 'Completed' : 'Do Review'} </JournalLink> </LinkContainer> </Links> @@ -51,7 +62,7 @@ const ReviewerItem = ({ version, journals, currentUser, reviewerResponse }) => { <ActionContainer> <Button onClick={() => { - reviewerResponse(version, 'accepted') + reviewerResponse(currentUser.id, 'accepted', team.id) }} > accept @@ -63,7 +74,7 @@ const ReviewerItem = ({ version, journals, currentUser, reviewerResponse }) => { <ActionContainer> <Button onClick={() => { - reviewerResponse(version, 'rejected') + reviewerResponse(currentUser.id, 'rejected', team.id) }} > reject diff --git a/packages/components/xpub-dashboard/src/graphql/DashboardPage.js b/packages/components/xpub-dashboard/src/graphql/DashboardPage.js deleted file mode 100644 index 75f623dd515209c53abd543b72d7291eaf695b02..0000000000000000000000000000000000000000 --- a/packages/components/xpub-dashboard/src/graphql/DashboardPage.js +++ /dev/null @@ -1,41 +0,0 @@ -import { compose, withProps } from 'recompose' -import { graphql } from 'react-apollo' -import { withLoader } from 'pubsweet-client' -import queries from './queries/' -// import mutations from './mutations/' -import Dashboard from '../components/Dashboard' -import upload from '../lib/upload' - -export default compose( - graphql(queries.myManuscripts, { - options: { context: { online: false } }, - }), - // graphql(mutations.deleteProjectMutation, { - // props: ({ mutate }) => ({ - // deleteProject: project => mutate({ variables: { id: project.id } }), - // }), - // options: { - // update: ( - // proxy, - // { - // data: { - // deleteCollection: { id }, - // }, - // }, - // ) => { - // const data = proxy.readQuery({ query }) - // const collectionIndex = data.collections.findIndex(col => col.id === id) - // if (collectionIndex > -1) { - // data.collections.splice(collectionIndex, 1) - // proxy.writeQuery({ query, data }) - // } - // }, - // }, - // }), - withLoader(), - withProps(({ manuscripts: { manuscripts }, currentUser }) => ({ - dashboard: manuscripts, - currentUser, - })), - upload, -)(Dashboard) diff --git a/packages/components/xpub-dashboard/src/graphql/mutations/index.js b/packages/components/xpub-dashboard/src/graphql/mutations/index.js index 966ec567d147f5edc73fa9ad941950c755995800..5d541248b34c9b493f57f23d938c08b357684b85 100644 --- a/packages/components/xpub-dashboard/src/graphql/mutations/index.js +++ b/packages/components/xpub-dashboard/src/graphql/mutations/index.js @@ -2,16 +2,34 @@ import gql from 'graphql-tag' export default { deleteManuscriptMutation: gql` - mutation($id: ID) { - deleteManuscript(id: $id) { - id - } + mutation($id: ID!) { + deleteManuscript(id: $id) } `, reviewerResponseMutation: gql` - mutation($id: ID!, $response: String) { - reviewerResponse(id: $id, response: $response) { + mutation($currentUserId: ID!, $action: String, $teamId: ID!) { + reviewerResponse( + currentUserId: $currentUserId + action: $action + teamId: $teamId + ) { id + role + teamType + name + object { + objectId + objectType + } + objectType + members { + id + username + } + status { + user + status + } } } `, @@ -27,20 +45,27 @@ export default { createManuscript(input: $input) { id created + manuscriptVersions { + id + } teams { + id + role name + teamType object { - id + objectId + objectType } objectType members { - user { - id - username - } + id + username + } + status { + user status } - role } status reviews { diff --git a/packages/components/xpub-dashboard/src/graphql/queries/index.js b/packages/components/xpub-dashboard/src/graphql/queries/index.js index 8fc43073bfecb7379dbd6970a082e0852c1ee68c..718ab9d9c353cc98c5335f1cc5558c7dba1bab03 100644 --- a/packages/components/xpub-dashboard/src/graphql/queries/index.js +++ b/packages/components/xpub-dashboard/src/graphql/queries/index.js @@ -11,31 +11,39 @@ export default { journals { id - journalTitle + title manuscripts { id + manuscriptVersions { + id + } reviews { open recommendation created + isDecision user { id username } } teams { + id role + teamType name object { - id + objectId + objectType } objectType members { + id + username + } + status { + user status - user { - id - username - } } } status @@ -60,4 +68,16 @@ export default { } } `, + getUser: gql` + query GetUser($id: ID!) { + user(id: $id) { + id + username + admin + teams { + id + } + } + } + `, } diff --git a/packages/components/xpub-dashboard/src/lib/upload.js b/packages/components/xpub-dashboard/src/lib/upload.js index 96195ca98d5c5e36d633953bbf8c54d06bad76bb..213c9c921026f0ca43ca3459880b35987a9ddc4b 100644 --- a/packages/components/xpub-dashboard/src/lib/upload.js +++ b/packages/components/xpub-dashboard/src/lib/upload.js @@ -32,39 +32,47 @@ const inkConvertPromise = file => ({ data }) => { ) } -const createManuscriptPromise = (file, client) => ({ fileURL, response }) => { +const createManuscriptPromise = (file, client, currentUser) => ({ + fileURL, + response, +}) => { if (!response.converted) { throw new Error('The file was not converted') } const source = response.converted + const title = extractTitle(source) || generateTitle(file.name) const manuscript = { - created: new Date(), // TODO: set on server files: [ { - created: new Date(), // TODO: set on server - type: 'manuscript', filename: file.name, url: fileURL, mimeType: file.type, + size: file.size, }, ], meta: { title, - source, + source: source === true ? 'false' : source, }, - status: 'new', } return client.mutate({ mutation: mutations.createManuscriptMutation, variables: { input: manuscript }, update: (proxy, { data: { createManuscript } }) => { - const data = proxy.readQuery({ query: queries.dashboard }) + let data = proxy.readQuery({ query: queries.dashboard }) data.journals.manuscripts.push(createManuscript) proxy.writeQuery({ query: queries.dashboard, data }) + + data = proxy.readQuery({ + query: queries.getUser, + variables: { id: currentUser.id }, + }) + data.user.teams.push(createManuscript.teams[0]) + proxy.writeQuery({ query: queries.getUser, data }) }, }) } @@ -91,24 +99,26 @@ const skipInkConversion = file => export default compose( withApollo, withRouter, - withProps(({ client, setConversionState, history, journals }) => ({ - uploadManuscript: files => { - const [file] = files - setConversionState(() => ({ converting: true })) - return Promise.resolve() - .then(uploadPromise(files, client)) - .then( - skipInkConversion(file) - ? ({ data }) => - Promise.resolve({ - fileURL: data.upload.url, - response: { converted: true }, - }) - : inkConvertPromise(file), - ) - .then(createManuscriptPromise(file, client)) - .then(redirectPromise(setConversionState, journals, history)) - .catch(error => setConversionState(() => ({ error }))) - }, - })), + withProps( + ({ client, setConversionState, history, journals, currentUser }) => ({ + uploadManuscript: files => { + const [file] = files + setConversionState(() => ({ converting: true })) + return Promise.resolve() + .then(uploadPromise(files, client)) + .then( + skipInkConversion(file) + ? ({ data }) => + Promise.resolve({ + fileURL: data.upload.url, + response: { converted: true }, + }) + : inkConvertPromise(file), + ) + .then(createManuscriptPromise(file, client, currentUser)) + .then(redirectPromise(setConversionState, journals, history)) + .catch(error => setConversionState(() => ({ error }))) + }, + }), + ), ) diff --git a/packages/components/xpub-formbuilder/package.json b/packages/components/xpub-formbuilder/package.json index c5f2494b519dba588bc9137ec30bd4658fc5ac39..2d2eb2d2e42c5b4900423d57079a732f8c742299 100644 --- a/packages/components/xpub-formbuilder/package.json +++ b/packages/components/xpub-formbuilder/package.json @@ -12,6 +12,7 @@ "dependencies": { "@pubsweet/ui": "^9.1.2", "@pubsweet/ui-toolkit": "^2.0.6", + "formik": "^1.4.2", "passport": "^0.4.0", "prop-types": "^15.5.10", "pubsweet-component-ink-frontend": "^1.0.2", @@ -30,12 +31,16 @@ "babel-preset-env": "^1.6.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-2": "^6.24.1", + "enzyme": "^3.7.0", + "enzyme-adapter-react-16": "^1.6.0", "faker": "^4.1.0" }, "peerDependencies": { "config": "^3.0.1", + "graphql-tag": "^2.10.0", "lodash": "^4.17.11", "pubsweet-client": ">=2.1.0", - "react": ">=16" + "react": ">=16", + "react-apollo": "^2.3.3" } } diff --git a/packages/components/xpub-formbuilder/src/components/ComponentProperties.jsx b/packages/components/xpub-formbuilder/src/components/ComponentProperties.jsx index 989f040bb9f535674b97f4fd4541ecf5a7076a2a..49e206ed7373e1bd7f29c4437a67c679768a16bf 100644 --- a/packages/components/xpub-formbuilder/src/components/ComponentProperties.jsx +++ b/packages/components/xpub-formbuilder/src/components/ComponentProperties.jsx @@ -1,6 +1,5 @@ import React from 'react' import { map, omitBy } from 'lodash' -// import styled from 'styled-components' import { branch, renderComponent, @@ -9,8 +8,8 @@ import { withHandlers, withProps, } from 'recompose' -import { ValidatedField, Menu, Button } from '@pubsweet/ui' -import { FormSection, reduxForm } from 'redux-form' +import { ValidatedFieldFormik, Menu, Button } from '@pubsweet/ui' +import { withFormik } from 'formik' import FormProperties from './FormProperties' import components from './config/Elements' @@ -33,31 +32,43 @@ const ComponentProperties = ({ changeComponent, selectComponentValue, handleSubmit, + setFieldValue, }) => ( <Page> <form onSubmit={handleSubmit}> <Heading>Component Properties</Heading> - <FormSection name="children"> - <Section> - <Legend space>Choose Component</Legend> - <ValidatedField - component={MenuComponents} - name="component" - onChange={(value, v) => changeComponent(v)} - /> - </Section> - {selectComponentValue && - map(components[selectComponentValue], (value, key) => ( - <Section> - <Legend space>{`Field ${key}`}</Legend> - <ValidatedField - component={elements[value.component].default} - name={key} - {...value.props} - /> - </Section> - ))} - </FormSection> + <Section> + <Legend space>Choose Component</Legend> + <ValidatedFieldFormik + component={MenuComponents} + name="component" + onChange={value => { + changeComponent(value) + setFieldValue('component', value) + }} + /> + </Section> + {selectComponentValue && + map(components[selectComponentValue], (value, key) => ( + <Section> + <Legend space>{`Field ${key}`}</Legend> + <ValidatedFieldFormik + component={elements[value.component].default} + key={`${selectComponentValue}-${key}`} + name={key} + onChange={event => { + let value = {} + if (event.target) { + value = event.target.value + } else { + value = event + } + setFieldValue(key, value) + }} + {...value.props} + /> + </Section> + ))} <Button primary type="submit"> Update Component </Button> @@ -73,10 +84,10 @@ const UpdateForm = ({ onSubmitFn, properties, changeTabs }) => ( /> ) -const onSubmit = (values, dispatch, { onSubmitFn, id, properties }) => { - if (!values.children.id || !values.children.component) return +const onSubmit = (values, { onSubmitFn, properties }) => { + if (!values.id || !values.component) return - const children = omitBy(values.children, value => value === '') + const children = omitBy(values, value => value === '') onSubmitFn({ id: properties.id }, Object.assign({}, { children })) } @@ -84,11 +95,11 @@ const ComponentForm = compose( withProps(({ properties }) => ({ initialValues: { children: properties.properties }, })), - reduxForm({ - form: 'ComponentSubmit', - onSubmit, - enableReinitialize: true, - destroyOnUnmount: false, + withFormik({ + displayName: 'ComponentSubmit', + mapPropsToValues: data => data.properties.properties, + handleSubmit: (props, { props: { onSubmitFn, id, properties } }) => + onSubmit(props, { onSubmitFn, properties }), }), withState( 'selectComponentValue', diff --git a/packages/components/xpub-formbuilder/src/components/FormBuilder.jsx b/packages/components/xpub-formbuilder/src/components/FormBuilder.jsx index 1d0e3350ceb53d64a4cdca1a47cc749e3c65d798..25c39956dd32239c31fd3fa5bd3b730b1f16c49b 100644 --- a/packages/components/xpub-formbuilder/src/components/FormBuilder.jsx +++ b/packages/components/xpub-formbuilder/src/components/FormBuilder.jsx @@ -87,7 +87,7 @@ const BuilderElement = ({ elements, changeProperties, deleteElement, form }) => <ElementTitle dangerouslySetInnerHTML={createMarkup(value.title)} /> ( {value.component}) </Action> - <Action onClick={() => deleteElement(form, value)}>x</Action> + <Action onClick={() => deleteElement(form.id, value.id)}>x</Action> </Element> )) diff --git a/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.jsx b/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.jsx index 955ce625bdddda2909efb7d390651a587d54213e..84d4c667cad1ad59e46edc82c11ed45f715b94ca 100644 --- a/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.jsx +++ b/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.jsx @@ -18,7 +18,7 @@ const AdminStyled = styled(Admin)` ` const FormBuilderLayout = ({ - forms, + getForms, properties, deleteForm, deleteElement, @@ -30,7 +30,7 @@ const FormBuilderLayout = ({ activeTab, }) => { const Sections = [] - forEach(forms, (form, key) => { + forEach(getForms, (form, key) => { Sections.push({ content: ( <FormBuilder @@ -47,7 +47,7 @@ const FormBuilderLayout = ({ key={`delete-form-${key}`} onClick={e => { e.preventDefault() - deleteForm(form) + deleteForm(form.id) }} > x @@ -68,7 +68,6 @@ const FormBuilderLayout = ({ key: 'new', label: '+ Add Form', }) - return ( <Columns> <Tabs @@ -76,7 +75,7 @@ const FormBuilderLayout = ({ onChange={tab => { changeProperties({ type: 'form', - properties: forms[tab], + properties: getForms[tab], }) changeTabs(tab) }} diff --git a/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.md b/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.md index 5d833ec05827e28cbb38d1460a9bbf78198dda9d..ca3d1efb62d3a48bd517d1a319597203229e15af 100644 --- a/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.md +++ b/packages/components/xpub-formbuilder/src/components/FormBuilderLayout.md @@ -38,7 +38,7 @@ initialState = { } ;<div style={{ position: 'relative', height: '100%' }}> <FormBuilderLayout - forms={forms} + getForms={forms} activeTab={state.activeTab} properties={state.properties} changeProperties={value => { diff --git a/packages/components/xpub-formbuilder/src/components/FormBuilderPage.js b/packages/components/xpub-formbuilder/src/components/FormBuilderPage.js index 7b3f5455203284a01d4c28b77934ec41798179a2..c9cc7dd795931f559690573575bf62517e64872c 100644 --- a/packages/components/xpub-formbuilder/src/components/FormBuilderPage.js +++ b/packages/components/xpub-formbuilder/src/components/FormBuilderPage.js @@ -1,57 +1,122 @@ -import { compose, withState, withHandlers } from 'recompose' -import { connect } from 'react-redux' -import { actions } from 'pubsweet-client' - -// import config from 'config' -import { ConnectPage } from 'xpub-connect' +import { compose, withState, withHandlers, withProps } from 'recompose' +import { graphql } from 'react-apollo' +import gql from 'graphql-tag' +import { withLoader } from 'pubsweet-client' import FormBuilderLayout from './FormBuilderLayout' -import { - createForms, - updateForms, - deleteForms, - deleteElements, - getForms, - updateElements, -} from '../redux/FormBuilder' +const createForm = gql` + mutation($form: String!) { + createForm(form: $form) + } +` -export default compose( - ConnectPage(() => [actions.getUsers(), getForms()]), - connect( - state => { - const { error } = state - const { forms } = state.forms +const updateForm = gql` + mutation($form: String!, $id: String!) { + updateForm(form: $form, id: $id) + } +` - return { error, forms: forms.forms } - }, - (dispatch, { history }) => ({ - deleteForm: form => dispatch(deleteForms(form)), - deleteElement: (form, element) => dispatch(deleteElements(form, element)), - updateForm: (form, formProperties) => - dispatch(updateForms(form, formProperties)), - createForm: formProperties => dispatch(createForms(formProperties)), - updateElements: (form, formElements) => - dispatch(updateElements(form, formElements)), - }), - ), - withState('properties', 'onChangeProperties', ({ forms }) => ({ +const updateFormElements = gql` + mutation($form: String!, $formId: String!) { + updateFormElements(form: $form, formId: $formId) + } +` + +const deleteFormElement = gql` + mutation($formId: ID!, $elementId: ID!) { + deleteFormElement(formId: $formId, elementId: $elementId) + } +` + +const deleteForms = gql` + mutation($formId: ID!) { + deleteForms(formId: $formId) + } +` + +const query = gql` + query { + currentUser { + id + username + admin + } + + getForms + } +` + +export default compose( + graphql(query), + graphql(deleteForms, { + name: 'deleteForms', + }), + graphql(deleteFormElement, { + name: 'deleteFormElement', + }), + graphql(updateForm, { + name: 'updateForm', + }), + graphql(createForm, { + name: 'createForm', + }), + graphql(updateFormElements, { + name: 'updateFormElements', + }), + withLoader(), + withProps(props => ({ + deleteForm: formId => + props.deleteForms({ + variables: { + formId, + }, + }), + deleteElement: (formId, elementId) => + props.deleteFormElement({ + variables: { + formId, + elementId, + }, + }), + updateForm: (form, formProperties) => + props.updateForm({ + variables: { + form: JSON.stringify(formProperties), + id: form.id, + }, + }), + createForm: formProperties => + props.createForm({ + variables: { + form: JSON.stringify(formProperties), + }, + }), + updateElements: (form, formElements) => + props.updateFormElements({ + variables: { + form: JSON.stringify(formElements), + formId: form.id, + }, + }), + })), + withState('properties', 'onChangeProperties', ({ getForms }) => ({ type: 'form', - properties: forms[0] || {}, + properties: getForms[0] || {}, })), - withState('activeTab', 'onChangeTab', ({ forms, activeTab }) => - forms.length === 0 ? 'new' : 0, + withState('activeTab', 'onChangeTab', ({ getForms, activeTab }) => + getForms.length === 0 ? 'new' : 0, ), withHandlers({ changeProperties: ({ onChangeProperties, - forms, + getForms, activeTab, }) => properties => onChangeProperties( () => Object.assign({}, properties, { - id: (forms[activeTab] || {}).id, + id: (getForms[activeTab] || {}).id, }) || undefined, ), changeTabs: ({ onChangeTab }) => activeTab => { diff --git a/packages/components/xpub-formbuilder/src/components/FormProperties.jsx b/packages/components/xpub-formbuilder/src/components/FormProperties.jsx index 507ed90b1d81acbca73a73bb571968cf92e428a7..c50d59fd1eab546ff2db9b483c379673d844eff8 100644 --- a/packages/components/xpub-formbuilder/src/components/FormProperties.jsx +++ b/packages/components/xpub-formbuilder/src/components/FormProperties.jsx @@ -1,10 +1,10 @@ import React from 'react' +import { withFormik } from 'formik' import { pick, isEmpty } from 'lodash' import styled from 'styled-components' import { compose, withProps, withState, withHandlers } from 'recompose' -import { Button, TextField, ValidatedField } from '@pubsweet/ui' +import { Button, TextField, ValidatedFieldFormik } from '@pubsweet/ui' import { th } from '@pubsweet/ui-toolkit' -import { FormSection, reduxForm } from 'redux-form' import { AbstractField, RadioBox } from './builderComponents' import { Page, Heading } from './molecules/Page' @@ -22,11 +22,7 @@ export const Section = styled.div` margin: calc(${th('gridUnit')} * 6) 0; ` -const onSubmit = ( - values, - dispatch, - { onSubmitFn, properties, mode, changeTabs }, -) => { +const onSubmit = (values, { onSubmitFn, properties, mode }) => { if (mode === 'create') { onSubmitFn(Object.assign({}, values)) } else { @@ -40,6 +36,8 @@ const FormProperties = ({ mode, selectPopup, showPopupValue, + values, + setFieldValue, }) => isEmpty(properties.properties) && mode !== 'create' ? ( <Page> @@ -49,58 +47,65 @@ const FormProperties = ({ <Page> <form onSubmit={handleSubmit}> <Heading>{mode === 'create' ? 'Create Form' : 'Update Form'}</Heading> - <FormSection name=""> - <Section id="form.id" key="form.id"> - <Legend>ID Form</Legend> - <ValidatedField component={idText} name="id" /> - </Section> - <Section id="form.name" key="form.name"> - <Legend>Form Name</Legend> - <ValidatedField component={nameText} name="name" /> - </Section> - <Section id="form.description" key="form.description"> + <Section id="form.id" key="form.id"> + <Legend>ID Form</Legend> + <ValidatedFieldFormik component={idText} name="id" /> + </Section> + <Section id="form.name" key="form.name"> + <Legend>Form Name</Legend> + <ValidatedFieldFormik component={nameText} name="name" /> + </Section> + <Section id="form.description" key="form.description"> + <Legend>Description</Legend> + <ValidatedFieldFormik + component={AbstractField.default} + name="description" + onChange={val => { + setFieldValue('description', val) + }} + /> + </Section> + <Section id="form.submitpopup" key="form.submitpopup"> + <Legend>Submit on Popup</Legend> + <ValidatedFieldFormik + component={RadioBox.default} + inline + name="haspopup" + onChange={(input, value) => { + setFieldValue('haspopup', input) + selectPopup(input) + }} + options={[ + { + label: 'Yes', + value: 'true', + }, + { + label: 'No', + value: 'false', + }, + ]} + /> + </Section> + {showPopupValue === 'true' && [ + <Section id="popup.title" key="popup.title"> + <Legend>Popup Title</Legend> + <ValidatedFieldFormik component={nameText} name="popuptitle" /> + </Section>, + <Section id="popup.description" key="popup.description"> <Legend>Description</Legend> - <ValidatedField + <ValidatedFieldFormik component={AbstractField.default} - name="description" - /> - </Section> - <Section id="form.submitpopup" key="form.submitpopup"> - <Legend>Submit on Popup</Legend> - <ValidatedField - component={RadioBox.default} - inline - name="haspopup" - onChange={(input, value) => selectPopup(value)} - options={[ - { - label: 'Yes', - value: 'true', - }, - { - label: 'No', - value: 'false', - }, - ]} + name="popupdescription" + onChange={val => { + setFieldValue('popupdescription', val) + }} /> - </Section> - {showPopupValue === 'true' && [ - <Section id="popup.title" key="popup.title"> - <Legend>Popup Title</Legend> - <ValidatedField component={nameText} name="popuptitle" /> - </Section>, - <Section id="popup.description" key="popup.description"> - <Legend>Description</Legend> - <ValidatedField - component={AbstractField.default} - name="popupdescription" - /> - </Section>, - ]} - <Button primary type="submit"> - {mode === 'create' ? 'Create Form' : 'Update Form'} - </Button> - </FormSection> + </Section>, + ]} + <Button primary type="submit"> + {mode === 'create' ? 'Create Form' : 'Update Form'} + </Button> </form> </Page> ) @@ -127,10 +132,10 @@ export default compose( withHandlers({ changeShowPopup: ({ selectPopup }) => value => selectPopup(() => value), }), - reduxForm({ - form: 'FormSubmit', - onSubmit, - enableReinitialize: true, - destroyOnUnmount: false, + withFormik({ + displayName: 'FormSubmit', + mapPropsToValues: data => data.properties.properties, + handleSubmit: (props, { props: { mode, onSubmitFn, properties } }) => + onSubmit(props, { mode, onSubmitFn, properties }), }), )(FormProperties) diff --git a/packages/components/xpub-formbuilder/src/components/builderComponents/Menu.js b/packages/components/xpub-formbuilder/src/components/builderComponents/Menu.js index 1e59339f6b770149e55f6c078ecfbe4c161233bd..b64e39bd16a45f047072d7ac6c7da8a0253e6fed 100644 --- a/packages/components/xpub-formbuilder/src/components/builderComponents/Menu.js +++ b/packages/components/xpub-formbuilder/src/components/builderComponents/Menu.js @@ -1,5 +1,5 @@ import React from 'react' -import { Menu, TextField, ValidatedField } from '@pubsweet/ui' +import { Menu, TextField, ValidatedFieldFormik } from '@pubsweet/ui' import { compose, withState, withHandlers } from 'recompose' import { Legend, Section } from '../styles' @@ -14,7 +14,7 @@ const ValidationMenu = input => ( {input.selectelement && input.selectelement !== 'required' && ( <Section> <Legend space>FIeld Min / Max</Legend> - <ValidatedField + <ValidatedFieldFormik component={TextField} name={`validateValue.${input.selectelement}`} /> diff --git a/packages/components/xpub-formbuilder/src/components/builderComponents/OptionsField.js b/packages/components/xpub-formbuilder/src/components/builderComponents/OptionsField.js index 5b2c40877b3c215ea466f5672ecc1c92c8d5b270..f429cf6180950dea655f6681720876a5a7f66c7d 100644 --- a/packages/components/xpub-formbuilder/src/components/builderComponents/OptionsField.js +++ b/packages/components/xpub-formbuilder/src/components/builderComponents/OptionsField.js @@ -1,7 +1,7 @@ import React from 'react' import styled from 'styled-components' -import { FieldArray } from 'redux-form' -import { TextField, ValidatedField, Button } from '@pubsweet/ui' +import { FieldArray } from 'formik' +import { TextField, ValidatedFieldFormik, Button } from '@pubsweet/ui' const Inline = styled.div` display: inline-block; @@ -29,41 +29,38 @@ const valueInput = input => ( <TextField label="Value Option" placeholder="Enter value…" {...input} /> ) -const renderOptions = ({ - fields = {}, - meta: { touched, error, submitFailed }, -}) => ( +const renderOptions = ({ form: { values }, push, remove }) => ( <ul> <UnbulletedList> <li> - <Button onClick={() => fields.push()} plain type="button"> + <Button onClick={() => push()} plain type="button"> Add another option </Button> </li> - {fields.map((option, index) => ( + {(values.options || []).map((option, index) => ( <li> <Spacing> <Option> Option: - {fields.length > 1 && ( - <Button onClick={() => fields.remove(index)} type="button"> + {values.options.length > 1 && ( + <Button onClick={() => remove(index)} type="button"> Remove </Button> )} </Option> <div> <Inline> - <ValidatedField + <ValidatedFieldFormik component={keyInput} - name={`${option}.label`} + name={`options.${index}.label`} required /> </Inline> <Inline> - <ValidatedField + <ValidatedFieldFormik component={valueInput} - name={`${option}value`} + name={`options.${index}.value`} required /> </Inline> diff --git a/packages/components/xpub-formbuilder/src/test/FormBuilderLayout.test.js b/packages/components/xpub-formbuilder/src/test/FormBuilderLayout.test.js index f91a4369231c6e86d85ed4c8f4041adae433f972..bc7b005ffbe9b1a58319ac5b1f44a411ecd4311a 100644 --- a/packages/components/xpub-formbuilder/src/test/FormBuilderLayout.test.js +++ b/packages/components/xpub-formbuilder/src/test/FormBuilderLayout.test.js @@ -1,13 +1,11 @@ import React from 'react' +import faker from 'faker' import Enzyme, { mount } from 'enzyme' import { MemoryRouter } from 'react-router-dom' -import { Provider } from 'react-redux' +import { MockedProvider } from 'react-apollo/test-utils' import Adapter from 'enzyme-adapter-react-16' import { ThemeProvider } from 'styled-components' -import { combineReducers } from 'redux' -import configureMockStore from 'redux-mock-store' -import thunk from 'redux-thunk' -import { reducers } from 'pubsweet-client' +import gql from 'graphql-tag' import FormProperties from '../components/FormProperties' import FormBuilderLayout from '../components/FormBuilderLayout' @@ -43,31 +41,35 @@ jest.mock('config', () => ({ }, })) -const reducer = combineReducers(reducers) +const query = gql` + query { + currentUser { + id + username + admin + } -const middlewares = [thunk] -const mockStore = () => - configureMockStore(middlewares)(actions => - Object.assign( - actions.reduce(reducer, { - currentUser: { isAuthenticated: true }, - }), - (actions.users || []).reduce(reducer, { - users: { - users: [ - { id: '1', username: 'author' }, - { id: '2', username: 'managing Editor' }, - ], - }, - }), - { forms: { forms: { noforms } } }, - ), - ) + getForms + } +` + +const mocks = [ + { + request: { + query, + }, + result: { + data: { + currentUser: { id: faker.random.uuid(), username: 'test', admin: true }, + getForms: noforms, + }, + }, + }, +] describe('FormBuilder Layout', () => { - const makeWrapper = (props = {}) => { - const store = mockStore() - return mount( + const makeWrapper = (props = {}) => + mount( <MemoryRouter> <ThemeProvider theme={{ @@ -75,13 +77,12 @@ describe('FormBuilder Layout', () => { colorSecondary: '#E7E7E7', }} > - <Provider store={store}> + <MockedProvider addTypename={false} mocks={mocks}> <FormBuilderLayout {...props} /> - </Provider> + </MockedProvider> </ThemeProvider> </MemoryRouter>, ) - } it('shows just the create form tab', () => { const formbuilder = makeWrapper({ @@ -117,7 +118,7 @@ describe('FormBuilder Layout', () => { properties: testforms[0], }, activeTab: 0, - forms: testforms, + getForms: testforms, }) expect( diff --git a/packages/components/xpub-formbuilder/src/test/FormBuilderPage.integration.test.js b/packages/components/xpub-formbuilder/src/test/FormBuilderPage.integration.test.js index 9ebe3221ea296f8cd1fde2f46bb0d3e6b6b064e1..11db339fa1177b09a589ee8857b9c096edec5106 100644 --- a/packages/components/xpub-formbuilder/src/test/FormBuilderPage.integration.test.js +++ b/packages/components/xpub-formbuilder/src/test/FormBuilderPage.integration.test.js @@ -1,16 +1,14 @@ import React from 'react' +import faker from 'faker' import { MemoryRouter } from 'react-router-dom' -import { Provider } from 'react-redux' -import { combineReducers } from 'redux' -import configureMockStore from 'redux-mock-store' -import thunk from 'redux-thunk' +import { MockedProvider } from 'react-apollo/test-utils' +import gql from 'graphql-tag' + import Enzyme, { mount } from 'enzyme' import Adapter from 'enzyme-adapter-react-16' import { ThemeProvider } from 'styled-components' -import { reducers } from 'pubsweet-client' - import FormBuilderPage from '../components/FormBuilderPage' import forms from './config/test.json' @@ -52,45 +50,43 @@ jest.mock('config', () => { } }) -// Mock out the API -jest.mock('pubsweet-client/src/helpers/api', () => ({ - get: jest.fn(url => { - // Whatever the request is, return an empty array - const response = [] - return new Promise(resolve => resolve(response)) - }), -})) - -jest.mock('pubsweet-client/src/helpers/Authorize', () => 'Authorize') +jest.mock( + 'pubsweet-client/src/helpers/AuthorizeWithGraphQL', + () => 'AuthorizeWithGraphQL', +) global.window.localStorage = { getItem: jest.fn(() => 'tok123'), } -const reducer = combineReducers(reducers) +const query = gql` + query { + currentUser { + id + username + admin + } -const middlewares = [thunk] -const mockStore = () => - configureMockStore(middlewares)(actions => - Object.assign( - actions.reduce(reducer, { - currentUser: { isAuthenticated: true }, - }), - (actions.users || []).reduce(reducer, { - users: { - users: [ - { id: '1', username: 'author' }, - { id: '2', username: 'managing Editor' }, - ], - }, - }), - { forms: { forms: { forms } } }, - ), - ) + getForms + } +` + +const mocks = [ + { + request: { + query, + }, + result: { + data: { + currentUser: { id: faker.random.uuid(), username: 'test', admin: true }, + getForms: forms, + }, + }, + }, +] describe('FormBuilderPage', () => { it('runs', done => { - const store = mockStore() const page = mount( <MemoryRouter> <ThemeProvider @@ -99,20 +95,20 @@ describe('FormBuilderPage', () => { colorSecondary: '#E7E7E7', }} > - <Provider store={store}> + <MockedProvider addTypename={false} mocks={mocks}> <FormBuilderPage /> - </Provider> + </MockedProvider> </ThemeProvider> </MemoryRouter>, ) - setImmediate(() => { + setTimeout(() => { page.update() expect(page.find('#builder-element').children()).toHaveLength( forms[0].children.length, ) expect(page.find(FormProperties)).toHaveLength(1) done() - }) + }, 1000) }) }) diff --git a/packages/components/xpub-manuscript/src/components/Manuscript.js b/packages/components/xpub-manuscript/src/components/Manuscript.js index cbf7bec6e7c7a8d31e267671e47b6e558d455d4a..679963765faba00b40029710a5a879407168129d 100644 --- a/packages/components/xpub-manuscript/src/components/Manuscript.js +++ b/packages/components/xpub-manuscript/src/components/Manuscript.js @@ -28,9 +28,8 @@ const Manuscript = ({ history, updateManuscript, }) => - file && - file.fileType === - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ? ( + file.mimeType === + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ? ( <ManuScript> <Wax fileUpload={fileUpload} diff --git a/packages/components/xpub-manuscript/src/components/ManuscriptPage.js b/packages/components/xpub-manuscript/src/components/ManuscriptPage.js index a292018a7a052e945e584ff0277ba502658db472..9e4d83fa76f2b458d1946fcdd2750e82c3229b65 100644 --- a/packages/components/xpub-manuscript/src/components/ManuscriptPage.js +++ b/packages/components/xpub-manuscript/src/components/ManuscriptPage.js @@ -11,8 +11,8 @@ const fragmentFields = ` status files { id - type fileType + mimeType } meta { title @@ -42,9 +42,9 @@ export default compose( }, }), }), - withProps(({ data }) => ({ - content: data.manuscript.content, - file: data.files.filter(file => file.type === 'manuscript'), - })), withLoader(), + withProps(({ manuscript }) => ({ + content: manuscript.meta.source, + file: manuscript.files.find(file => file.fileType === 'manuscript') || {}, + })), )(Manuscript) diff --git a/packages/components/xpub-review/package.json b/packages/components/xpub-review/package.json index b0563b600c108e6d1378d9cb17f27bc842071084..55dac7a471feb4d5e6134b9b888ae765a5e312a9 100644 --- a/packages/components/xpub-review/package.json +++ b/packages/components/xpub-review/package.json @@ -48,6 +48,7 @@ }, "peerDependencies": { "apollo-client-preset": "^1.0.8", + "config": "^3.0.1", "formik": "^1.4.2", "pubsweet-client": ">=2.1.0", "react": ">=16", diff --git a/packages/components/xpub-review/src/components/DecisionPage.js b/packages/components/xpub-review/src/components/DecisionPage.js index c8599f06e5a22ccdab66da712e9854cfc9c3d469..29127570b5365b34a52140e01c68de1fdb5e428a 100644 --- a/packages/components/xpub-review/src/components/DecisionPage.js +++ b/packages/components/xpub-review/src/components/DecisionPage.js @@ -3,10 +3,36 @@ import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' import { withFormik } from 'formik' import { withLoader } from 'pubsweet-client' +import { getCommentContent } from './review/util' -import uploadFile from 'xpub-upload' import DecisionLayout from './decision/DecisionLayout' +const reviewFields = ` + id + created + updated + comments { + type + content + files { + id + created + label + filename + fileType + mimeType + size + url + } + } + isDecision + recommendation + user { + id + username + } +` + const fragmentFields = ` id created @@ -15,68 +41,37 @@ const fragmentFields = ` created label filename + fileType mimeType - type size url } reviews { - open - recommendation - created - comments { - type - content - files { - type - id - label - url - filename - } - } - user { - id - username - } - } - decision { - status - created - comments { - type - content - files { - type - id - label - url - filename - } - } - user { - id - username - } + ${reviewFields} } + decision teams { id - role + name + teamType object { - id + objectId + objectType } objectType members { + id + username + } + status { + user status - user { - id - username - } } } status meta { title + source abstract declarations { openData @@ -93,8 +88,6 @@ const fragmentFields = ` date } notes { - id - created notesType content } @@ -129,6 +122,37 @@ const query = gql` } ` +const updateReviewMutation = gql` + mutation($id: ID, $input: ReviewInput) { + updateReview(id: $id, input: $input) { + ${reviewFields} + } + } +` + +const uploadReviewFilesMutation = gql` + mutation($file: Upload!) { + upload(file: $file) { + url + } + } +` + +const createFileMutation = gql` + mutation($file: Upload!) { + createFile(file: $file) { + id + created + label + filename + fileType + mimeType + size + url + } + } +` + const submitMutation = gql` mutation($id: ID!, $input: String) { submitManuscript(id: $id, input: $input) { @@ -146,13 +170,51 @@ export default compose( }, }), }), + graphql(uploadReviewFilesMutation, { name: 'uploadReviewFilesMutation' }), + graphql(updateReviewMutation, { name: 'updateReviewMutation' }), + graphql(createFileMutation, { + props: ({ mutate, ownProps: { match } }) => ({ + createFile: file => { + mutate({ + variables: { + file, + }, + update: (proxy, { data: { createFile } }) => { + const data = proxy.readQuery({ + query, + variables: { + id: match.params.version, + }, + }) + + data.manuscript.reviews.map(review => { + if (review.id === file.objectId) { + review.comments.map(comment => { + if (comment.type === createFile.fileType) { + comment.files = [createFile] + } + return comment + }) + } + return review + }) + + proxy.writeQuery({ query, data }) + }, + }) + }, + }), + }), graphql(submitMutation, { props: ({ mutate, ownProps }) => ({ - onSubmit: (manuscript, { history }) => { + completeDecision: ({ history, manuscript }) => { mutate({ variables: { id: manuscript.id, - input: JSON.stringify({ decision: manuscript.decision }), + input: JSON.stringify({ + decision: manuscript.reviews.find(review => review.isDecision) + .recommendation, + }), }, }).then(() => { history.push('/') @@ -161,15 +223,104 @@ export default compose( }), }), withLoader(), - withProps(({ getFile, manuscript, match: { params: { journal } } }) => ({ - journal: { id: journal }, - uploadFile, - })), + withProps( + ({ + currentUser, + manuscript, + createFile, + updateReviewMutation, + uploadReviewFilesMutation, + match: { + params: { journal }, + }, + }) => ({ + journal: { id: journal }, + updateReview: (data, file) => { + const reviewData = { + isDecision: true, + manuscriptId: manuscript.id, + } + + if (data.comment) { + reviewData.comments = [data.comment] + } + + if (data.recommendation) { + reviewData.recommendation = data.recommendation + } + + const review = + manuscript.reviews.find(review => review.isDecision) || {} + return updateReviewMutation({ + variables: { + id: review.id || undefined, + input: reviewData, + }, + update: (proxy, { data: { updateReview } }) => { + const data = proxy.readQuery({ + query, + variables: { + id: manuscript.id, + }, + }) + const reviewIndex = data.manuscript.reviews.findIndex( + review => review.id === updateReview.id, + ) + if (reviewIndex < 0) { + data.manuscript.reviews.push(updateReview) + } else { + data.manuscript.reviews[reviewIndex] = updateReview + } + proxy.writeQuery({ query, data }) + }, + }) + }, + uploadFile: (file, updateReview, type) => + uploadReviewFilesMutation({ + variables: { + file, + }, + }).then(({ data }) => { + const newFile = { + url: data.upload.url, + filename: file.name, + size: file.size, + object: 'Review', + objectId: updateReview.id, + fileType: type, + } + createFile(newFile) + }), + }), + ), withFormik({ - initialValues: {}, - mapPropsToValues: ({ manuscript }) => manuscript, + mapPropsToValues: props => + props.manuscript.reviews.find(review => review.isDecision) || { + comments: [], + recommendation: null, + }, + isInitialValid: ({ manuscript }) => { + const rv = manuscript.reviews.find(review => review.isDecision) || {} + const isRecommendation = rv.recommendation != null + const isCommented = getCommentContent(rv, 'note') !== '' + + return isCommented && isRecommendation + }, + validate: (values, props) => { + const errors = {} + if (getCommentContent(values, 'note') === '') { + errors.comments = 'Required' + } + + if (values.recommendation === null) { + errors.recommendation = 'Required' + } + return errors + }, displayName: 'decision', - handleSubmit: (props, { props: { onSubmit, history } }) => - onSubmit(props, { history }), + handleSubmit: ( + props, + { props: { completeDecision, history, manuscript } }, + ) => completeDecision({ history, manuscript }), }), )(DecisionLayout) diff --git a/packages/components/xpub-review/src/components/ReviewPage.js b/packages/components/xpub-review/src/components/ReviewPage.js index d665ca67109367af7d9e803265f3894bb1a74a82..9b95a98603db87f4b4fe230fbd60de4ba31cbe78 100644 --- a/packages/components/xpub-review/src/components/ReviewPage.js +++ b/packages/components/xpub-review/src/components/ReviewPage.js @@ -1,12 +1,52 @@ -// import { debounce } from 'lodash' import { compose, withProps } from 'recompose' import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' import { withFormik } from 'formik' import { withLoader } from 'pubsweet-client' -// import uploadFile from 'xpub-upload' +import { cloneDeep } from 'lodash' +import { getCommentContent } from './review/util' import ReviewLayout from '../components/review/ReviewLayout' +const reviewFields = ` + id + created + updated + comments { + type + content + files { + id + created + label + filename + fileType + mimeType + size + url + } + } + isDecision + recommendation + user { + id + username + } +` + +const teamFields = ` + id + name + teamType + object { + objectId + objectType + } + members { + id + username + } +` + const fragmentFields = ` id created @@ -15,68 +55,37 @@ const fragmentFields = ` created label filename + fileType mimeType - type size url } reviews { - open - recommendation - created - comments { - type - content - files { - type - id - label - url - filename - } - } - user { - id - username - } - } - decision { - status - created - comments { - type - content - files { - type - id - label - url - filename - } - } - user { - id - username - } + ${reviewFields} } + decision teams { id - role + name + teamType object { - id + objectId + objectType } objectType members { + id + username + } + status { + user status - user { - id - username - } } } status meta { title + source abstract declarations { openData @@ -93,8 +102,6 @@ const fragmentFields = ` date } notes { - id - created notesType content } @@ -129,11 +136,41 @@ const query = gql` } ` -const submitReviewMutation = gql` - mutation($id: ID!, $input: String) { - updateManuscript(id: $id, input: $input) { +const updateTeam = gql` + mutation($id: ID!, $input: TeamInput) { + updateTeam(id: $id, input: $input) { + ${teamFields} + } + } +` + +const updateReviewMutation = gql` + mutation($id: ID, $input: ReviewInput) { + updateReview(id: $id, input: $input) { + ${reviewFields} + } + } +` + +const uploadReviewFilesMutation = gql` + mutation($file: Upload!) { + upload(file: $file) { + url + } + } +` + +const createFileMutation = gql` + mutation($file: Upload!) { + createFile(file: $file) { id - ${fragmentFields} + created + label + filename + fileType + mimeType + size + url } } ` @@ -143,40 +180,156 @@ export default compose( options: ({ match }) => ({ variables: { id: match.params.version, - form: 'submit', }, }), }), - graphql(submitReviewMutation, { - props: ({ mutate, ownProps: { data } }) => ({ - onSubmit: (review, { history }) => { + graphql(uploadReviewFilesMutation, { name: 'uploadReviewFilesMutation' }), + graphql(updateReviewMutation, { name: 'updateReviewMutation' }), + graphql(updateTeam, { name: 'updateTeam' }), + graphql(createFileMutation, { + props: ({ mutate, ownProps: { match } }) => ({ + createFile: file => { mutate({ variables: { - id: data.manuscript.id, - input: JSON.stringify(review), + file, + }, + update: (proxy, { data: { createFile } }) => { + const data = proxy.readQuery({ + query, + variables: { + id: match.params.version, + }, + }) + + data.manuscript.reviews.map(review => { + if (review.id === file.objectId) { + review.comments.map(comment => { + if (comment.type === createFile.fileType) { + comment.files = [createFile] + } + return comment + }) + } + return review + }) + + proxy.writeQuery({ query, data }) }, - }).then(() => { - history.push('/') }) }, }), }), withLoader(), - withProps(({ manuscript, currentUser, match: { params: { journal } } }) => ({ - journal: { id: journal }, - review: manuscript.reviews.find( - review => review.user.id === currentUser.id, - ), - status: manuscript.teams - .find(team => team.role === 'reviewerEditor') - .members.find(member => member.user.id === currentUser.id).status, - })), + withProps( + ({ + manuscript, + currentUser, + match: { + params: { journal }, + }, + updateReviewMutation, + uploadReviewFilesMutation, + updateTeam, + createFile, + }) => ({ + journal: { id: journal }, + review: + manuscript.reviews.find( + review => review.user.id === currentUser.id && !review.isDecision, + ) || {}, + status: ( + ( + ( + manuscript.teams.find(team => team.teamType === 'reviewerEditor') || + {} + ).status || [] + ).find(status => status.user === currentUser.id) || {} + ).status, + updateReview: (review, file) => { + ;(review.comments || []).map(comment => { + delete comment.files + delete comment.__typename + return comment + }) + + const reviewData = { + recommendation: review.recommendation, + comments: review.comments, + manuscriptId: manuscript.id, + } + + return updateReviewMutation({ + variables: { + id: review.id || undefined, + input: reviewData, + }, + update: (proxy, { data: { updateReview } }) => { + const data = proxy.readQuery({ + query, + variables: { + id: manuscript.id, + }, + }) + let reviewIndex = data.manuscript.reviews.findIndex( + review => review.id === updateReview.id, + ) + reviewIndex = reviewIndex < 0 ? 0 : reviewIndex + data.manuscript.reviews[reviewIndex] = updateReview + proxy.writeQuery({ query, data }) + }, + }) + }, + uploadFile: (file, updateReview, type) => + uploadReviewFilesMutation({ + variables: { + file, + }, + }).then(({ data }) => { + const newFile = { + url: data.upload.url, + filename: file.name, + mimeType: file.type, + size: file.size, + object: 'Review', + objectId: updateReview.id, + fileType: type, + } + createFile(newFile) + }), + completeReview: history => { + const team = cloneDeep(manuscript.teams).find( + team => team.teamType === 'reviewerEditor', + ) + + team.status.map(status => { + if (status.user === currentUser.id) { + status.status = 'completed' + } + delete status.__typename + return status + }) + updateTeam({ + variables: { + id: team.id, + input: { + status: team.status, + }, + }, + }).then(() => { + history.push('/') + }) + }, + }), + ), withFormik({ - initialValues: {}, - mapPropsToValues: ({ manuscript, currentUser }) => - manuscript.reviews.find(review => review.user.id === currentUser.id), + isInitialValid: ({ review }) => { + const isRecommendation = review.recommendation !== '' + const isCommented = getCommentContent(review, 'note') !== '' + + return isCommented && isRecommendation + }, displayName: 'review', - handleSubmit: (props, { props: { onSubmit, history } }) => - onSubmit(props, { history }), + handleSubmit: (props, { props: { completeReview, history } }) => + completeReview(history), }), )(ReviewLayout) diff --git a/packages/components/xpub-review/src/components/ReviewersPage.js b/packages/components/xpub-review/src/components/ReviewersPage.js index 825519a37c9e69fe027fcd8413889d40a8354b51..873ec9d69a0a2cff85031514563ca7c470428792 100644 --- a/packages/components/xpub-review/src/components/ReviewersPage.js +++ b/packages/components/xpub-review/src/components/ReviewersPage.js @@ -1,12 +1,33 @@ -import { compose, withProps } from 'recompose' +import { compose, withProps, withHandlers } from 'recompose' +import { withFormik } from 'formik' import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' import { withLoader } from 'pubsweet-client' +import { cloneDeep, omit } from 'lodash' import Reviewers from '../components/reviewers/Reviewers' -import ReviewerFormContainer from '../components/reviewers/ReviewerFormContainer' import ReviewerContainer from '../components/reviewers/ReviewerContainer' +const teamFields = ` + id + role + teamType + name + object { + objectId + objectType + } + objectType + members { + id + username + } + status { + user + status + } +` + const fragmentFields = ` id created @@ -16,7 +37,7 @@ const fragmentFields = ` label filename mimeType - type + fileType size url } @@ -28,26 +49,7 @@ const fragmentFields = ` type content files { - type - id - label - url - filename - } - } - user { - id - username - } - } - decision { - status - created - comments { - type - content - files { - type + fileType id label url @@ -59,72 +61,25 @@ const fragmentFields = ` username } } + decision teams { - id - role - object { - id - } - objectType - members { - status - user { - id - username - } - } + ${teamFields} } status - meta { - title - abstract - declarations { - openData - openPeerReview - preregistered - previouslySubmitted - researchNexus - streamlinedReview - } - articleSections - articleType - history { - type - date - } - notes { - id - created - notesType - content - } - keywords - } - suggestions { - reviewers { - opposed - suggested - } - editors { - opposed - suggested +` + +const createTeamMutation = gql` + mutation($input: TeamInput!) { + createTeam(input: $input) { + ${teamFields} } } ` -const teamFields = ` - id - role - name - object { - id - } - objectType - members { - status - user { - id - username +const updateTeamMutation = gql` + mutation($id: ID, $input: TeamInput) { + updateTeam(id: $id, input: $input) { + ${teamFields} } } ` @@ -153,6 +108,81 @@ const query = gql` } ` +const update = match => (proxy, { data: { updateTeam, createTeam } }) => { + const data = proxy.readQuery({ + query, + variables: { + id: match.params.version, + }, + }) + + if (updateTeam) { + const teamIndex = data.teams.findIndex(team => team.id === updateTeam.id) + const manuscriptTeamIndex = data.manuscript.teams.findIndex( + team => team.id === updateTeam.id, + ) + data.teams[teamIndex] = updateTeam + data.manuscript.teams[manuscriptTeamIndex] = updateTeam + } + + if (createTeam) { + data.teams.push(createTeam) + data.manuscript.teams.push(createTeam) + } + proxy.writeQuery({ query, data }) +} + +const handleSubmit = ( + { user }, + { props: { manuscript, updateTeamMutation, createTeamMutation, match } }, +) => { + const team = + manuscript.teams.find(team => team.teamType === 'reviewerEditor') || {} + + const teamAdd = { + object: { + objectId: manuscript.id, + objectType: 'Manuscript', + }, + status: [{ user: user.id, status: 'invited' }], + name: 'Reviewer Editor', + teamType: 'reviewerEditor', + members: [user.id], + } + if (team.id) { + const newTeam = { + object: omit(team.object, ['__typename']), + status: team.status.map(status => omit(status, ['__typename'])), + name: team.name, + teamType: team.teamType, + members: cloneDeep(team.members).map(member => member.id), + } + + newTeam.members.push(user.id) + newTeam.status.push({ user: user.id, status: 'invited' }) + updateTeamMutation({ + variables: { + id: team.id, + input: newTeam, + }, + update: update(match), + }) + } else { + createTeamMutation({ + variables: { + input: teamAdd, + }, + update: update(match), + }) + } +} + +const loadOptions = props => input => { + const options = props.reviewerUsers + + return Promise.resolve({ options }) +} + export default compose( graphql(query, { options: ({ match }) => ({ @@ -161,22 +191,49 @@ export default compose( }, }), }), + graphql(createTeamMutation, { name: 'createTeamMutation' }), + graphql(updateTeamMutation, { name: 'updateTeamMutation' }), withLoader(), - withProps(({ manuscript, teams, users, match: { params: { journal } } }) => { - const reviewerTeams = - manuscript.teams.find( - team => - team.role === 'reviewerEditor' && - team.object.id === manuscript.id && - team.objectType === 'manuscript', - ) || {} - - return { - reviewers: reviewerTeams.members || [], - journal: { id: journal }, - reviewerUsers: users, - Reviewer: ReviewerContainer, - ReviewerForm: ReviewerFormContainer, - } + withProps( + ({ + manuscript, + teams = [], + users, + match: { + params: { journal }, + }, + }) => { + const reviewerTeams = + teams.find( + team => + team.teamType === 'reviewerEditor' && + team.object.objectId === manuscript.id && + team.object.objectType === 'Manuscript', + ) || {} + + // Temporary solution until new Team model is back + const mem = cloneDeep(reviewerTeams.members || []) + mem.map(member => { + const status = reviewerTeams.status.find( + status => status.user === member.id, + ) + member.status = (status || {}).status + return member + }) + return { + reviewers: mem || [], + journal: { id: journal }, + reviewerUsers: users, + Reviewer: ReviewerContainer, + } + }, + ), + withHandlers({ + loadOptions: props => loadOptions(props), + }), + withFormik({ + mapPropsToValues: () => ({ user: '' }), + displayName: 'reviewers', + handleSubmit, }), )(Reviewers) diff --git a/packages/components/xpub-review/src/components/assignEditors/AssignEditor.js b/packages/components/xpub-review/src/components/assignEditors/AssignEditor.js index 43f071d17f079c96694f65d0615b66a599445957..24a82abd96280249c8571d0f943c04095dfbc4eb 100644 --- a/packages/components/xpub-review/src/components/assignEditors/AssignEditor.js +++ b/packages/components/xpub-review/src/components/assignEditors/AssignEditor.js @@ -1,10 +1,11 @@ import React from 'react' +import config from 'config' import { compose, withProps } from 'recompose' -import { cloneDeep } from 'lodash' +import { cloneDeep, get } from 'lodash' import { Menu } from '@pubsweet/ui' import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' -// import { addUserToTeam } from '../../redux/teams' +import { withLoader } from 'pubsweet-client' const editorOption = user => ({ label: user.username, // TODO: name @@ -13,18 +14,15 @@ const editorOption = user => ({ const teamFields = ` id - role name + teamType object { - id + objectId + objectType } - objectType members { - status - user { - id - username - } + id + username } ` @@ -35,27 +33,42 @@ const query = gql` username admin } - - teams { - ${teamFields} - } } ` const updateTeam = gql` - mutation($id: ID!, $input: String) { - assignTeamEditor(id: $id, input: $input) { + mutation($id: ID!, $input: TeamInput) { + updateTeam(id: $id, input: $input) { ${teamFields} } } ` +const createTeamMutation = gql` + mutation($input: TeamInput!) { + createTeam(input: $input) { + ${teamFields} + } + } +` + // TODO: select multiple editors -const AssignEditor = ({ updateTeam, teamName, teamRole, value, options }) => ( +const AssignEditor = ({ + updateTeam, + createTeam, + teamName, + teamRole, + value, + options, +}) => ( <Menu label={teamName} onChange={user => { - updateTeam(user, teamRole) + if (value) { + updateTeam(user, teamRole) + } else { + createTeam(user, teamRole) + } }} options={options} placeholder="Assign an editor…" @@ -68,54 +81,63 @@ export default compose( graphql(updateTeam, { props: ({ mutate, ownProps }) => { const updateTeam = (userId, teamRole) => { - const teams = cloneDeep(ownProps.data.teams).find( - team => - team.role === teamRole && - team.object.id === ownProps.manuscript.id && - team.objectType === 'manuscript', + const team = cloneDeep(ownProps.manuscript.teams).find( + team => team.teamType === teamRole, ) + mutate({ + variables: { + id: team.id, + input: { + members: [userId], + }, + }, + }) + } - const member = teams.members.find(member => member.user.id === userId) - const team = cloneDeep(teams) - team.members = [member] - - const { manuscript } = cloneDeep(ownProps) - const replacePrevious = manuscript.teams.filter( - team => team.role !== teamRole, - ) - replacePrevious.push(team) + return { + updateTeam, + } + }, + }), + graphql(createTeamMutation, { + props: ({ mutate, ownProps }) => { + const createTeam = (userId, teamRole) => { + const input = { + object: { + objectId: ownProps.manuscript.id, + objectType: 'Manuscript', + }, + name: + teamRole === 'seniorEditor' ? 'Senior Editor' : 'Handling Editor', + teamType: teamRole, + members: [userId], + } mutate({ variables: { - id: ownProps.manuscript.id, - input: JSON.stringify({ teams: replacePrevious }), + input, }, }) } return { - updateTeam, + createTeam, } }, }), - withProps(({ teamRole, manuscript, data: { users, teams } }) => { - const filteredTeams = (teams || []).find( - team => - team.role === teamRole && - team.object.id === manuscript.id && - team.objectType === 'manuscript', - ) - const members = ((filteredTeams || {}).members || []).map( - members => members.user, - ) - const optionUsers = members.map(user => editorOption(user)) + withProps(({ teamRole, manuscript, data = {} }) => { + const optionUsers = (data.users || []).map(user => editorOption(user)) + + const team = + (manuscript.teams || []).find(team => team.teamType === teamRole) || {} + const members = team.members || [] + const teamName = get(config, `authsome.teams.${teamRole}.name`) return { - filteredTeams, - teams, - teamName: (filteredTeams || {}).name, + teamName, options: optionUsers, - value: manuscript.teams.find(team => team.teamRole), + value: members.length > 0 ? members[0].id : undefined, } }), + withLoader(), )(AssignEditor) diff --git a/packages/components/xpub-review/src/components/decision/Decision.js b/packages/components/xpub-review/src/components/decision/Decision.js index bbcf4ef30a210466abcf7fb3c880afaff4ca2c3c..7cdb3ff2e67c3148c8b56ea49fa5968352ef3ce7 100644 --- a/packages/components/xpub-review/src/components/decision/Decision.js +++ b/packages/components/xpub-review/src/components/decision/Decision.js @@ -22,17 +22,17 @@ const filesToAttachment = file => ({ url: file.url, }) -const Decision = ({ decision }) => ( +const Decision = ({ review }) => ( <div> <div> - {findComments(decision, 'note') && [ + {findComments(review, 'note') && [ <Heading>Note</Heading>, <Note> <Content> - <NoteViewer value={findComments(decision, 'note').content} /> + <NoteViewer value={findComments(review, 'note').content} /> </Content> - {findComments(decision, 'note') && - (findComments(decision, 'note').files || []).map(attachment => ( + {findComments(review, 'note') && + (findComments(review, 'note').files || []).map(attachment => ( <Attachment file={filesToAttachment(attachment)} key={attachment.url} @@ -46,7 +46,7 @@ const Decision = ({ decision }) => ( <div> <Heading>Decision</Heading> - <DecisionStatus>{decision.status}</DecisionStatus> + <DecisionStatus>{review.recommendation}</DecisionStatus> </div> </div> ) diff --git a/packages/components/xpub-review/src/components/decision/DecisionForm.js b/packages/components/xpub-review/src/components/decision/DecisionForm.js index 3cfa79462fd8797e1c2099607f7e7562438be5c0..f03b00ed16f0ac4c71cab367d85065506df056fc 100644 --- a/packages/components/xpub-review/src/components/decision/DecisionForm.js +++ b/packages/components/xpub-review/src/components/decision/DecisionForm.js @@ -1,104 +1,153 @@ import React from 'react' import { NoteEditor } from 'xpub-edit' -import { Button, RadioGroup, FileUploadList, UploadingFile } from '@pubsweet/ui' +import { cloneDeep, omit } from 'lodash' import { FieldArray, Field } from 'formik' import { withJournal } from 'xpub-journal' import { required } from 'xpub-validators' +import { + Button, + Flexbox, + RadioGroup, + UploadButton, + UploadingFile, +} from '@pubsweet/ui' -import AdminSection from '../atoms/AdminSection' - -const stripHtml = htmlString => { - const temp = document.createElement('span') - temp.innerHTML = htmlString - return temp.textContent -} +import { + getCommentFiles, + getCommentContent, + stripHtml, + createComments, +} from '../review/util' -const createComments = (values, val) => - Object.assign( - { - type: 'note', - content: '', - files: [], - }, - values.decision.comments[0], - val, - ) +import AdminSection from '../atoms/AdminSection' -const NoteDecision = uploadFile => props => ( +const NoteDecision = (updateReview, uploadFile) => props => ( <AdminSection> - <Field component={NoteInput} validate={required} {...props} /> - <Field component={AttachmentsInput} uploadFile={uploadFile} {...props} /> + <Field + component={NoteInput} + name="comments" + updateReview={updateReview} + validate={required} + /> + <Field + component={AttachmentsInput('note')} + updateReview={updateReview} + uploadFile={uploadFile} + /> </AdminSection> ) -const NoteInput = ({ field, form: { values, handleChange }, replace }) => ( +const NoteInput = ({ + field, + form: { values, setFieldValue }, + updateReview, +}) => ( <NoteEditor - {...field} - onChange={val => { - replace(0, createComments(values, { content: stripHtml(val) })) + key="note-input" + onBlur={value => { + const { updateIndex, comment } = createComments( + values, + { + type: 'note', + content: stripHtml(value), + }, + 'note', + ) + + setFieldValue(`comments.${updateIndex}`, comment) + updateReview( + cloneDeep(omit({ comment }, ['comment.files', 'comment.__typename'])), + ) }} placeholder="Write/paste your decision letter here, or upload it using the upload button on the right." title="Decision" - value={field.value.length > 0 ? field.value[0].content : ''} + value={getCommentContent({ comments: field.value }, 'note')} /> ) -const AttachmentsInput = ({ +const AttachmentsInput = type => ({ field, - form: { values, handleChange }, - replace, -}) => ( - <FileUploadList + form: { values, setFieldValue }, + updateReview, + uploadFile, +}) => [ + <UploadButton buttonText="↑ Upload files" - FileComponent={UploadingFile} - files={(values.decision.comments[0] || {}).files || []} - uploadFile={val => { - const file = { - filename: val.name, - name: val.name, - size: val.size, - fileType: val.type, - type: 'note', - } - replace(0, createComments(values, { files: [file] })) + key="note-attachment" + onChange={event => { + const val = event.target.files[0] + const file = cloneDeep(val) + file.filename = val.name + file.type = type + + const { updateIndex, comment } = createComments( + field.value, + { files: [file] }, + type, + ) + + setFieldValue(`comments.${updateIndex}.files`, comment.files) + + updateReview({}).then(({ data: { updateReview } }) => { + uploadFile(val, updateReview, type) + }) }} - /> -) + />, + <Flexbox> + {getCommentFiles(field.value, 'note').map(val => { + const file = cloneDeep(val) + file.name = file.filename + return <UploadingFile file={file} key={file.name} uploaded /> + })} + </Flexbox>, +] -const RecommendationInput = journal => ({ form, field }) => ( +const RecommendationInput = journal => ({ + field, + form: { setFieldValue }, + updateReview, +}) => ( <RadioGroup {...field} inline onChange={val => { - form.setFieldValue(`${field.name}`, val, true) + setFieldValue(`recommendation`, val) + updateReview({ recommendation: val }) }} options={journal.recommendations} - required + value={field.value === '' ? null : field.value} /> ) -const DecisionForm = ({ journal, handleSubmit, uploadFile, ...props }) => ( +const DecisionForm = ({ + journal, + handleSubmit, + uploadFile, + updateReview, + isValid, +}) => ( <form onSubmit={handleSubmit}> - <AdminSection> + <AdminSection key="note"> <div name="note"> <FieldArray - component={NoteDecision(uploadFile)} - name="decision.comments" + component={NoteDecision(updateReview, uploadFile)} + key="comments-array" + name="comments" /> </div> </AdminSection> - <AdminSection> + <AdminSection key="recommendation"> <Field component={RecommendationInput(journal)} - name="decision.status" + name="recommendation" + updateReview={updateReview} validate={required} - {...props} /> </AdminSection> - <AdminSection> - <Button primary type="submit"> + <AdminSection key="submit"> + <Button disabled={!isValid} primary type="submit"> Submit </Button> </AdminSection> diff --git a/packages/components/xpub-review/src/components/decision/DecisionLayout.js b/packages/components/xpub-review/src/components/decision/DecisionLayout.js index c424fb0a210737691694f7dbef345739dc2320ef..765d1d894a75b523a87136e8c228f684b2cecafa 100644 --- a/packages/components/xpub-review/src/components/decision/DecisionLayout.js +++ b/packages/components/xpub-review/src/components/decision/DecisionLayout.js @@ -20,23 +20,27 @@ const addEditor = (manuscript, label) => ({ const DecisionLayout = ({ handleSubmit, - handleChangeFn, + updateReview, uploadFile, manuscript, journal, + isValid, }) => { const decisionSections = [] const editorSections = [] - manuscript.manuscriptVersions.forEach(manuscript => { - const { decision } = manuscript - const submittedMoment = moment(decision.submitted) + const manuscriptVersions = manuscript.manuscriptVersions || [] + manuscriptVersions.forEach(manuscript => { + const submittedMoment = moment(manuscript.updated) const label = submittedMoment.format('YYYY-MM-DD') + decisionSections.push({ content: ( <div> <ReviewMetadata manuscript={manuscript} /> <DecisionReviews manuscript={manuscript} /> - <Decision decision={manuscript.decision} /> + <Decision + review={manuscript.reviews.find(review => review.isDecision)} + /> </div> ), key: manuscript.id, @@ -48,36 +52,39 @@ const DecisionLayout = ({ const submittedMoment = moment() const label = submittedMoment.format('YYYY-MM-DD') - decisionSections.push({ - content: ( - <div> - <AdminSection> - <AssignEditorsReviewers - AssignEditor={AssignEditor} - journal={journal} - manuscript={manuscript} - /> - </AdminSection> - <AdminSection> - <ReviewMetadata manuscript={manuscript} /> - </AdminSection> - <AdminSection> - <DecisionReviews manuscript={manuscript} /> - </AdminSection> - <AdminSection> - <DecisionForm - handleChangeFn={handleChangeFn} - handleSubmit={handleSubmit} - uploadFile={uploadFile} - /> - </AdminSection> - </div> - ), - key: manuscript.id, - label, - }) + if (manuscript.status !== 'revising') { + decisionSections.push({ + content: ( + <div> + <AdminSection key="assign-editors"> + <AssignEditorsReviewers + AssignEditor={AssignEditor} + journal={journal} + manuscript={manuscript} + /> + </AdminSection> + <AdminSection key="review-metadata"> + <ReviewMetadata manuscript={manuscript} /> + </AdminSection> + <AdminSection key="decision-review"> + <DecisionReviews manuscript={manuscript} /> + </AdminSection> + <AdminSection key="decision-form"> + <DecisionForm + handleSubmit={handleSubmit} + isValid={isValid} + updateReview={updateReview} + uploadFile={uploadFile} + /> + </AdminSection> + </div> + ), + key: manuscript.id, + label, + }) - editorSections.push(addEditor(manuscript, label)) + editorSections.push(addEditor(manuscript, label)) + } return ( <Columns> diff --git a/packages/components/xpub-review/src/components/decision/DecisionReviews.js b/packages/components/xpub-review/src/components/decision/DecisionReviews.js index 502e48d31e6b5b516e6e7171b585a9d87746e617..3ac96155eb105917b9fc0ccecf7e6ca838afc9c6 100644 --- a/packages/components/xpub-review/src/components/decision/DecisionReviews.js +++ b/packages/components/xpub-review/src/components/decision/DecisionReviews.js @@ -1,14 +1,19 @@ import React from 'react' -import { getUserFromTeam } from 'xpub-selectors' +// import { getUserFromTeam } from 'xpub-selectors' import DecisionReview from './DecisionReview' // TODO: read reviewer ordinal and name from project reviewer - +// const { status } = +// getUserFromTeam(manuscript, 'reviewerEditor').filter( +// member => member.user.id === currentUser.id, +// )[0] || {} +// return status const getCompletedReviews = (manuscript, currentUser) => { + const team = + manuscript.teams.find(team => team.teamType === 'reviewerEditor') || {} const { status } = - getUserFromTeam(manuscript, 'reviewerEditor').filter( - member => member.user.id === currentUser.id, - )[0] || {} + (team.status || []).filter(member => member.user === currentUser.id)[0] || + {} return status } @@ -18,8 +23,8 @@ const DecisionReviews = ({ manuscript }) => ( manuscript.reviews .filter( review => - getCompletedReviews(manuscript, review.user) === 'accepted' && - review.recommendation, + getCompletedReviews(manuscript, review.user) === 'completed' && + review.isDecision === false, ) .map((review, index) => ( <div key={review.id}> diff --git a/packages/components/xpub-review/src/components/decision/EditorSection.js b/packages/components/xpub-review/src/components/decision/EditorSection.js index 6489cc73f4e275167371d280e156b098fa4ab726..2c05b01f935474e3d7023458f577cdc2bd3f53e8 100644 --- a/packages/components/xpub-review/src/components/decision/EditorSection.js +++ b/packages/components/xpub-review/src/components/decision/EditorSection.js @@ -4,8 +4,8 @@ import { EditorWrapper } from '../molecules/EditorWrapper' import { Info } from '../molecules/Info' export default ({ manuscript }) => - ((manuscript.files || []).find(file => file.type === 'manuscript') || '') - .type === + ((manuscript.files || []).find(file => file.fileType === 'manuscript') || '') + .mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ? ( <EditorWrapper> <Wax key={manuscript.id} readonly value={manuscript.meta.source} /> diff --git a/packages/components/xpub-review/src/components/metadata/ReviewMetadata.js b/packages/components/xpub-review/src/components/metadata/ReviewMetadata.js index 5f7a4ab9dd3a00e8ff829f8d716ee32d8e4ccc3c..18cbfab494d37d627754e4ab0cb7deb85fee79c5 100644 --- a/packages/components/xpub-review/src/components/metadata/ReviewMetadata.js +++ b/packages/components/xpub-review/src/components/metadata/ReviewMetadata.js @@ -34,8 +34,11 @@ const Cell = styled.span` const getNote = (notes, type) => notes.find(note => note.notesType === type) || {} -const getSupplementaryFiles = (supplementary = []) => - supplementary.filter(file => file.type === 'supplementary') || [] +const getDeclarations = (manuscript, field) => + ((manuscript.meta || {}).declarations || {})[field] + +const getSupplementaryFiles = supplementary => + (supplementary || []).filter(file => file.fileType === 'supplementary') || [] // Due to migration to new Data Model // Attachement component needs different data structure to work @@ -52,13 +55,15 @@ const ReviewMetadata = ({ manuscript }) => ( <div> <Heading>Open Peer Review :</Heading> <Cell> - {manuscript.meta.declarations.openPeerReview === 'yes' ? 'Yes' : 'No'} + {getDeclarations(manuscript, 'openPeerReview') === 'yes' + ? 'Yes' + : 'No'} </Cell> </div> <div> <Heading>Streamlined Review :</Heading> <Cell> - {manuscript.meta.declarations.streamlinedReview === 'yes' + {getDeclarations(manuscript, 'streamlinedReview') === 'yes' ? 'Please view supplementary uploaded files' : 'No'} </Cell> @@ -66,20 +71,23 @@ const ReviewMetadata = ({ manuscript }) => ( <div> <Heading>Part of Research Nexus :</Heading> <Cell> - {manuscript.meta.declarations.researchNexus === 'yes' ? 'Yes' : 'No'} + {getDeclarations(manuscript, 'researchNexus') === 'yes' + ? 'Yes' + : 'No'} </Cell> </div> <div> <Heading>Pre-registered :</Heading> <Cell> - {manuscript.meta.declarations.preregistered === 'yes' ? 'Yes' : 'No'} + {getDeclarations(manuscript, 'preregistered') === 'yes' + ? 'Yes' + : 'No'} </Cell> </div> <div> <Heading>Suggested Reviewers :</Heading> <Cell> - {((manuscript.meta.suggestions || {}).reviewers || {}).suggested || - 'None'} + {((manuscript.suggestions || {}).reviewers || {}).suggested || 'None'} </Cell> </div> <div> diff --git a/packages/components/xpub-review/src/components/review/Review.js b/packages/components/xpub-review/src/components/review/Review.js index 944e443715b366e9a856c43f6c216fab67bd6321..6dafc509580015dcbc22d92795af470bdd45d9bb 100644 --- a/packages/components/xpub-review/src/components/review/Review.js +++ b/packages/components/xpub-review/src/components/review/Review.js @@ -3,6 +3,7 @@ import styled from 'styled-components' import { NoteViewer } from 'xpub-edit' import { Attachment } from '@pubsweet/ui' import { th } from '@pubsweet/ui-toolkit' +import { getCommentFiles } from './util' const Heading = styled.div`` const Note = styled.div` @@ -30,14 +31,13 @@ const ReviewComments = (review, type) => ( <Content> <NoteViewer value={findComments(review, type).content} /> </Content> - {findComments(review, type) && - findComments(review, type).files.map(attachment => ( - <Attachment - file={filesToAttachment(attachment)} - key={attachment.url} - uploaded - /> - ))} + {getCommentFiles(review, type).map(attachment => ( + <Attachment + file={filesToAttachment(attachment)} + key={attachment.url} + uploaded + /> + ))} </Note> ) @@ -57,12 +57,13 @@ const Review = ({ review }) => ( {ReviewComments(review, 'confidential')} </div> )} + {review.recommendation && ( + <div> + <Heading>Recommendation</Heading> - <div> - <Heading>Recommendation</Heading> - - <Recommendation>{review.recommendation}</Recommendation> - </div> + <Recommendation>{review.recommendation}</Recommendation> + </div> + )} </div> ) diff --git a/packages/components/xpub-review/src/components/review/ReviewForm.js b/packages/components/xpub-review/src/components/review/ReviewForm.js index 1d9c67fd14097a0f0700425a757047576ca11d0c..9902e1f898f4eabaf74a355b0c7951c1b1dc2cb3 100644 --- a/packages/components/xpub-review/src/components/review/ReviewForm.js +++ b/packages/components/xpub-review/src/components/review/ReviewForm.js @@ -1,143 +1,182 @@ import React from 'react' import styled from 'styled-components' - +import { cloneDeep, set } from 'lodash' import { Field, FieldArray } from 'formik' import { NoteEditor } from 'xpub-edit' -import { Button, RadioGroup, FileUploadList, UploadingFile } from '@pubsweet/ui' // Attachments +import { + Button, + Flexbox, + RadioGroup, + UploadButton, + UploadingFile, +} from '@pubsweet/ui' import { withJournal } from 'xpub-journal' -import { required } from 'xpub-validators' - +import { + getCommentFiles, + getCommentContent, + stripHtml, + createComments, +} from './util' import AdminSection from '../atoms/AdminSection' -const stripHtml = htmlString => { - const temp = document.createElement('span') - temp.innerHTML = htmlString - return temp.textContent -} - -const createComments = (values, val, type) => { - let updateIndex = values.comments.findIndex(comment => comment.type === type) - updateIndex = values.comments.length > 0 && updateIndex < 0 ? 1 : updateIndex - updateIndex = updateIndex < 0 ? 0 : updateIndex - - const comment = Object.assign( - { - type, - content: '', - files: [], - }, - values.comments[updateIndex], - val, - ) - - return { updateIndex, comment } -} - const AttachmentsInput = type => ({ field, - form: { values, handleChange }, - replace, -}) => ( - <FileUploadList + form: { values }, + updateReview, + uploadFile, + review, +}) => [ + <UploadButton buttonText="↑ Upload files" - FileComponent={UploadingFile} - files={ - (values.comments.find(comment => comment.type === type) || {}).files || [] - } - uploadFile={val => { - const file = { - filename: val.name, - name: val.name, - size: val.size, - fileType: val.type, - type: 'attachments', - } + onChange={event => { + const val = event.target.files[0] + const file = cloneDeep(val) + file.filename = val.name + file.type = type + const { updateIndex, comment } = createComments( - values, + review, { files: [file] }, type, ) - replace(updateIndex, comment) + + const data = cloneDeep(review) + set(data, `comments.${updateIndex}`, comment) + + updateReview(data).then(({ data: { updateReview } }) => { + uploadFile(val, updateReview, type) + }) }} - /> -) + />, + <Flexbox> + {getCommentFiles(review, type).map(val => { + const file = cloneDeep(val) + file.name = file.filename + return <UploadingFile file={file} key={file.name} uploaded /> + })} + </Flexbox>, +] -const NoteInput = ({ field, form: { values }, replace, push }) => ( +const NoteInput = ({ field, form: { values }, review, updateReview }) => ( <NoteEditor placeholder="Enter your review…" title="Comments to the Author" {...field} - onChange={value => { - const { updateIndex, comment } = createComments(values, { - type: 'note', - content: stripHtml(value), - }) - replace(updateIndex, comment) + onBlur={value => { + const { updateIndex, comment } = createComments( + values, + { + type: 'note', + content: stripHtml(value), + }, + 'note', + ) + + const data = cloneDeep(review) + set(data, `comments.${updateIndex}`, comment) + + updateReview(data) }} - value={ - (values.comments.find(value => value.type === 'note') || {}).content || '' - } + value={getCommentContent(review, 'note')} /> ) -const ConfidentialInput = ({ field, form: { values }, replace, push }) => ( +const ConfidentialInput = ({ field, review, updateReview }) => ( <NoteEditor placeholder="Enter a confidential note to the editor (optional)…" title="Confidential Comments to Editor (Optional)" {...field} - onChange={value => { - const { updateIndex, comment } = createComments(values, { - type: 'confidential', - content: stripHtml(value), - }) - replace(updateIndex, comment) + onBlur={value => { + const { updateIndex, comment } = createComments( + review, + { + type: 'confidential', + content: stripHtml(value), + }, + 'confidential', + ) + const data = cloneDeep(review) + set(data, `comments.${updateIndex}`, comment) + updateReview(data) }} - value={ - (values.comments.find(value => value.type === 'confidential') || {}) - .content || '' - } + value={getCommentContent(review, 'confidential')} /> ) -const RecommendationInput = journal => ({ form, field }) => ( +const RecommendationInput = journal => ({ field, updateReview, review }) => ( <RadioGroup inline - options={journal.recommendations} - {...field} onChange={val => { - form.setFieldValue(`${field.name}`, val, true) + const data = cloneDeep(review) + set(data, 'recommendation', val) + updateReview(data) }} + options={journal.recommendations} + value={review.recommendation} /> ) -const ReviewComment = uploadFile => props => [ +const ReviewComment = (updateReview, uploadFile, review) => props => [ <AdminSection> <div name="note"> - <Field component={NoteInput} validate={required} {...props} /> - <Field component={AttachmentsInput('note')} {...props} /> + <Field + component={NoteInput} + review={review} + updateReview={updateReview} + {...props} + /> + <Field + component={AttachmentsInput('note')} + review={review} + updateReview={updateReview} + uploadFile={uploadFile} + {...props} + /> </div> </AdminSection>, <AdminSection> <div name="confidential"> - <Field component={ConfidentialInput} {...props} /> - <Field component={AttachmentsInput('confidential')} {...props} /> + <Field + component={ConfidentialInput} + review={review} + updateReview={updateReview} + {...props} + /> + <Field + component={AttachmentsInput('confidential')} + review={review} + updateReview={updateReview} + uploadFile={uploadFile} + {...props} + /> </div> </AdminSection>, ] const Title = styled.div`` -const ReviewForm = ({ journal, isValid, handleSubmit, uploadFile }) => ( +const ReviewForm = ({ + journal, + isValid, + handleSubmit, + updateReview, + uploadFile, + review, +}) => ( <form onSubmit={handleSubmit}> - <FieldArray component={ReviewComment(uploadFile)} name="comments" /> + <FieldArray + component={ReviewComment(updateReview, uploadFile, review)} + name="comments" + /> <AdminSection> <div name="Recommendation"> <Title>Recommendation</Title> <Field component={RecommendationInput(journal)} name="recommendation" - validate={required} + review={review} + updateReview={updateReview} /> </div> </AdminSection> diff --git a/packages/components/xpub-review/src/components/review/ReviewLayout.js b/packages/components/xpub-review/src/components/review/ReviewLayout.js index f3868e218ae5a892403398955eb15ed9dcadfcf5..3872593897fe2b7aa6c1f800ddced47b245ac349 100644 --- a/packages/components/xpub-review/src/components/review/ReviewLayout.js +++ b/packages/components/xpub-review/src/components/review/ReviewLayout.js @@ -21,23 +21,22 @@ const ReviewLayout = ({ review, reviewer, handleSubmit, - uploadFile, isValid, status, + updateReview, + uploadFile, }) => { const reviewSections = [] const editorSections = [] - - manuscript.manuscriptVersions.forEach(manuscript => { + const manuscriptVersions = manuscript.manuscriptVersions || [] + manuscriptVersions.forEach(manuscript => { const label = moment().format('YYYY-MM-DD') reviewSections.push({ content: ( <div> <ReviewMetadata manuscript={manuscript} /> <Review - review={manuscript.reviews.find( - review => review.user.id === currentUser.id, - )} + review={manuscript.reviews.find(review => !review.isDecision) || {}} /> </div> ), @@ -48,29 +47,31 @@ const ReviewLayout = ({ editorSections.push(addEditor(manuscript, label)) }, []) - const label = moment().format('YYYY-MM-DD') - - reviewSections.push({ - content: ( - <div> - <ReviewMetadata manuscript={manuscript} /> - {status === 'accepted' && review.recommendation !== '' ? ( - <Review review={review} /> - ) : ( - <ReviewForm - handleSubmit={handleSubmit} - isValid={isValid} - uploadFile={uploadFile} - /> - )} - </div> - ), - key: manuscript.id, - label, - }) - - editorSections.push(addEditor(manuscript, label)) + if (manuscript.status !== 'revising') { + const label = moment().format('YYYY-MM-DD') + reviewSections.push({ + content: ( + <div> + <ReviewMetadata manuscript={manuscript} /> + {status === 'completed' ? ( + <Review review={review} /> + ) : ( + <ReviewForm + handleSubmit={handleSubmit} + isValid={isValid} + review={review} + updateReview={updateReview} + uploadFile={uploadFile} + /> + )} + </div> + ), + key: manuscript.id, + label, + }) + editorSections.push(addEditor(manuscript, label)) + } return ( <Columns> <Manuscript> diff --git a/packages/components/xpub-review/src/components/review/util.js b/packages/components/xpub-review/src/components/review/util.js new file mode 100644 index 0000000000000000000000000000000000000000..91551dc7e458bdadcb7e7f98554ec0aec72c09b4 --- /dev/null +++ b/packages/components/xpub-review/src/components/review/util.js @@ -0,0 +1,38 @@ +export const stripHtml = htmlString => { + const temp = document.createElement('span') + temp.innerHTML = htmlString + return temp.textContent +} + +export const getCommentFiles = (review = {}, type) => { + const comments = + (review.comments || []).find(comment => comment.type === type) || {} + return comments.files || [] +} + +export const getCommentContent = (review = {}, type) => { + const comments = + (review.comments || []).find(comment => comment.type === type) || {} + return comments.content || '' +} + +export const createComments = (values, val, type) => { + let updateIndex = (values.comments || []).findIndex( + comment => comment.type === type, + ) + updateIndex = + (values.comments || []).length > 0 && updateIndex < 0 ? 1 : updateIndex + updateIndex = updateIndex < 0 ? 0 : updateIndex + + const comment = Object.assign( + { + type, + content: '', + files: [], + }, + (values.comments || [])[updateIndex], + val, + ) + + return { updateIndex, comment } +} diff --git a/packages/components/xpub-review/src/components/reviewers/Reviewer.js b/packages/components/xpub-review/src/components/reviewers/Reviewer.js index 417172c26fa28fd2bbb8f8a6fac12306858616aa..5367ceb2650694472e5809b274ce85b54985ebaf 100644 --- a/packages/components/xpub-review/src/components/reviewers/Reviewer.js +++ b/packages/components/xpub-review/src/components/reviewers/Reviewer.js @@ -11,11 +11,6 @@ const Root = styled.div` padding: ${th('gridUnit')}; ` -// const Event = styled.div` -// font-size: ${th('fontSizeBaseSmall')}; -// line-height: ${th('lineHeightBaseSmall')}; -// ` - const ordinalLetter = ordinal => ordinal ? String.fromCharCode(96 + ordinal) : null @@ -24,10 +19,10 @@ const Reviewer = ({ reviewer, removeReviewer }) => ( <Avatar height={70} reviewerLetter={ordinalLetter(null)} - status={reviewer.status} + status={reviewer.status || ''} width={100} /> - <div>{reviewer.user.username}</div> + <div>{reviewer.username}</div> {/* <div> {map(reviewer.events, (event, key) => ( <Event key={`${key}-${event}`}> diff --git a/packages/components/xpub-review/src/components/reviewers/ReviewerForm.js b/packages/components/xpub-review/src/components/reviewers/ReviewerForm.js index 992fb0fdf2b5feb38566c1139a10bd8971d9d04e..7770e2c95c1c15ddc3e0a56d88e898eb2bd577f6 100644 --- a/packages/components/xpub-review/src/components/reviewers/ReviewerForm.js +++ b/packages/components/xpub-review/src/components/reviewers/ReviewerForm.js @@ -1,6 +1,5 @@ import React from 'react' import Select from 'react-select' -import { cloneDeep } from 'lodash' import { Field, FieldArray } from 'formik' import { Button } from '@pubsweet/ui' import { required } from 'xpub-validators' @@ -15,40 +14,21 @@ const OptionRenderer = option => ( const ReviewerInput = loadOptions => ({ field, - form: { values }, + form: { values, setFieldValue }, push, replace, }) => ( <Select.AsyncCreatable {...field} - // autoload={false} filterOption={() => true} labelKey="username" loadOptions={loadOptions} onChange={user => { - const teamIndex = (values.teams || []).findIndex( - team => team.role === 'reviewerEditor', - ) - - const member = { - status: 'invited', - user, - } - - if (teamIndex < 0) { - const team = { - role: 'reviewerEditor', - members: [member], - } - push(team) - } else { - const newTeam = cloneDeep(values.teams[teamIndex]) - newTeam.members.push(member) - replace(0, newTeam) - } + setFieldValue('user', user) }} optionRenderer={OptionRenderer} promptTextCreator={label => `Add ${label}?`} + value={values.user.id} valueKey="id" /> ) @@ -70,7 +50,7 @@ const ReviewerForm = ({ loadOptions, }) => ( <form onSubmit={handleSubmit}> - <FieldArray component={componentFields(loadOptions)} name="teams" /> + <FieldArray component={componentFields(loadOptions)} /> <Button disabled={!isValid} primary type="submit"> Invite reviewer diff --git a/packages/components/xpub-review/src/components/reviewers/ReviewerFormContainer.js b/packages/components/xpub-review/src/components/reviewers/ReviewerFormContainer.js index 432e24e660dee3c32f44759564bd1312b2d8b925..e0ba787d5a71fa0c6c81b799f6fc0509a695fa24 100644 --- a/packages/components/xpub-review/src/components/reviewers/ReviewerFormContainer.js +++ b/packages/components/xpub-review/src/components/reviewers/ReviewerFormContainer.js @@ -1,65 +1,122 @@ import { compose, withHandlers } from 'recompose' +import { cloneDeep } from 'lodash' import { withFormik } from 'formik' import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' import ReviewerForm from './ReviewerForm' -const fragmentFields = ` - created - reviews { - open - recommendation - created - comments { +const createTeamMutation = gql` + mutation($input: TeamInput!) { + createTeam(input: $input) { + id type - content - files { - type + teamType + name + object { + objectId + objectType + } + members { id - label - url - filename + username } } - user { - id - username - } } - teams { - id - role - object { +` + +const updateTeamMutation = gql` + mutation($id: ID, $input: TeamInput) { + updateTeam(id: $id, input: $input) { id - } - objectType - members { - status - user { + type + teamType + name + object { + objectId + objectType + } + members { id username } } } - status ` -const updateMutation = gql` - mutation($id: ID!, $input: String) { - updateManuscript(id: $id, input: $input) { +const query = gql` + query { + teams { id - ${fragmentFields} + teamType + name + object { + objectId + objectType + } + members { + id + } + status { + user + status + } } } ` -const handleSubmit = (manuscript, { props }) => { - props.updateMutation({ - variables: { - id: manuscript.id, - input: JSON.stringify(manuscript), +const update = ( + proxy, + { data: { updateTeamMutation, createTeamMutation, teams } }, +) => { + const data = proxy.readQuery({ query }) + if (updateTeamMutation) { + const teamIndex = teams.findIndex(team => team.id === updateTeamMutation.id) + data[teamIndex] = updateTeamMutation + } + + if (createTeamMutation) { + data.push(createTeamMutation) + } + + proxy.writeQuery({ query, data }) +} + +const handleSubmit = ( + { user }, + { props: { manuscript, updateTeamMutation, createTeamMutation } }, +) => { + const team = + manuscript.teams.find(team => team.teamType === 'reviewerEditor') || {} + + const teamAdd = { + object: { + objectId: manuscript.id, + objectType: 'Manuscript', }, - }) + status: [{ user: user.id, status: 'invited' }], + name: 'Reviewer Editor', + teamType: 'reviewerEditor', + members: [user.id], + } + if (team.id) { + const newTeam = cloneDeep(team) + newTeam.status.push({ user: user.id, status: 'invited' }) + newTeam.members.push(user.id) + updateTeamMutation({ + variables: { + id: team.id, + input: newTeam, + }, + update, + }) + } else { + createTeamMutation({ + variables: { + input: teamAdd, + }, + update, + }) + } } const loadOptions = props => input => { @@ -69,13 +126,13 @@ const loadOptions = props => input => { } export default compose( - graphql(updateMutation, { name: 'updateMutation' }), + graphql(createTeamMutation, { name: 'createTeamMutation' }), + graphql(updateTeamMutation, { name: 'updateTeamMutation' }), withHandlers({ loadOptions: props => loadOptions(props), }), withFormik({ - initialValues: {}, - mapPropsToValues: ({ manuscript }) => manuscript, + mapPropsToValues: () => ({ user: '' }), displayName: 'reviewers', handleSubmit, }), diff --git a/packages/components/xpub-review/src/components/reviewers/Reviewers.js b/packages/components/xpub-review/src/components/reviewers/Reviewers.js index 301ee2b9aee4eeefe01c8ce376e1f43dcfe390f9..7204e5b271c0ee31c48036d3f3ef3866aa119677 100644 --- a/packages/components/xpub-review/src/components/reviewers/Reviewers.js +++ b/packages/components/xpub-review/src/components/reviewers/Reviewers.js @@ -2,6 +2,7 @@ import React from 'react' import styled from 'styled-components' import { Link } from '@pubsweet/ui' import { th } from '@pubsweet/ui-toolkit' +import ReviewerForm from './ReviewerForm' const Root = styled.div` display: flex; @@ -14,22 +15,25 @@ const ReviewersList = styled.div` ` const Reviewers = ({ - ReviewerForm, Reviewer, journal, + isValid, + loadOptions, version, reviewers, reviewerUsers, manuscript, + handleSubmit, teams, }) => ( <Root> <Form> <ReviewerForm + handleSubmit={handleSubmit} + isValid={isValid} journal={journal} - manuscript={manuscript} + loadOptions={loadOptions} reviewerUsers={reviewerUsers} - teams={teams} /> <Link to={`/journals/${journal.id}/versions/${manuscript.id}/decisions/${ diff --git a/packages/components/xpub-selectors/src/index.js b/packages/components/xpub-selectors/src/index.js index ace90fa25f84189a26e1104f7966136cca343437..7e90b82663e8bd88624e66b36688b6c20657a179 100644 --- a/packages/components/xpub-selectors/src/index.js +++ b/packages/components/xpub-selectors/src/index.js @@ -52,7 +52,7 @@ export const getReviewerFromUser = (project, version, currentUser) => { export const getUserFromTeam = (version, role) => { if (!version.teams) return [] - const teams = version.teams.filter(team => team.role === role) + const teams = version.teams.filter(team => team.teamType === role) return teams.length ? teams[0].members : [] } diff --git a/packages/components/xpub-submit/package.json b/packages/components/xpub-submit/package.json index ae6094de10274b8cbc57c8b60aee9c28ca47fb12..92e0ad07077b2cadbe4823c5c8e6e77f9ae3f0b1 100644 --- a/packages/components/xpub-submit/package.json +++ b/packages/components/xpub-submit/package.json @@ -13,15 +13,16 @@ "@pubsweet/ui-toolkit": "^2.0.6", "apollo-client-preset": "^1.0.8", "formik": "^1.4.2", + "grid-styled": "^4.1.0", "lodash": "^4.17.11", "moment": "^2.23.0", "prop-types": "^15.5.10", "react-apollo": "^2.1.0", + "react-dropzone": "^4.3.0", + "react-feather": "^1.1.5", "react-html-parser": "^2.0.2", - "react-redux": "^5.0.2", "react-router-dom": "^4.2.2", "recompose": "^0.26.0", - "redux-form": "^7.0.3", "striptags": "^3.1.0", "styled-components": "^4.1.1", "xpub-connect": "^2.0.6", @@ -37,6 +38,8 @@ "babel-preset-env": "^1.6.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-2": "^6.24.1", + "enzyme": "^3.7.0", + "enzyme-adapter-react-16": "^1.6.0", "faker": "^4.1.0" }, "peerDependencies": { diff --git a/packages/components/xpub-submit/src/components/AuthorsInput.js b/packages/components/xpub-submit/src/components/AuthorsInput.js index 9fd750f050925a4f9cbb098e5e834bfe582790b8..ac7d255ef7ca84f6a7dd1942ead97db1ff99c8d4 100644 --- a/packages/components/xpub-submit/src/components/AuthorsInput.js +++ b/packages/components/xpub-submit/src/components/AuthorsInput.js @@ -1,6 +1,7 @@ import React from 'react' import styled from 'styled-components' import { FieldArray } from 'formik' +import { cloneDeep, set } from 'lodash' import { TextField, Button, ValidatedFieldFormik } from '@pubsweet/ui' import { minSize, readonly } from 'xpub-validators' @@ -44,7 +45,20 @@ const affiliationInput = input => ( <TextField label="Affiliation" placeholder="Enter affiliation…" {...input} /> ) -const renderAuthors = ({ form: { values }, insert, remove }) => ( +const onChangeFn = (onChange, setFieldValue, values) => value => { + const val = value.target ? value.target.value : value + setFieldValue(value.target.name, val, true) + + const data = cloneDeep(values) + set(data, value.target.name, val) + onChange(data.authors, 'authors') +} + +const renderAuthors = onChange => ({ + form: { values, setFieldValue }, + insert, + remove, +}) => ( <ul> <UnbulletedList> <li> @@ -64,7 +78,7 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( </Button> </li> {(values.authors || []).map((author, index) => ( - <li key={`author.${author.email}`}> + <li key={`author-${author}`}> <Spacing> <Author> Author: @@ -78,7 +92,8 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( <Inline> <ValidatedFieldFormik component={firstNameInput} - name={`authors.[${index}].firstName`} + name={`authors.${index}.firstName`} + onChange={onChangeFn(onChange, setFieldValue, values)} readonly={readonly} validate={minSize1} /> @@ -88,6 +103,7 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( <ValidatedFieldFormik component={lastNameInput} name={`authors.[${index}].lastName`} + onChange={onChangeFn(onChange, setFieldValue, values)} readonly={readonly} validate={minSize1} /> @@ -99,6 +115,7 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( <ValidatedFieldFormik component={emailAddressInput} name={`authors.[${index}].email`} + onChange={onChangeFn(onChange, setFieldValue, values)} readonly={readonly} validate={minSize1} /> @@ -108,8 +125,8 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( <ValidatedFieldFormik component={affiliationInput} name={`authors.[${index}].affiliation`} + onChange={onChangeFn(onChange, setFieldValue, values)} readonly={readonly} - required validate={minSize1} /> </Inline> @@ -121,8 +138,8 @@ const renderAuthors = ({ form: { values }, insert, remove }) => ( </ul> ) -const AuthorsInput = props => ( - <FieldArray name="authors" render={renderAuthors} /> +const AuthorsInput = ({ onChange }) => ( + <FieldArray name="authors" render={renderAuthors(onChange)} /> ) export default AuthorsInput diff --git a/packages/components/xpub-submit/src/components/DecisionReviewColumn.js b/packages/components/xpub-submit/src/components/DecisionReviewColumn.js index 0f932bd20cf26778728f507d34b27d6c94a46e85..e349f9eb1a0b450ae2bceb01a2b9faacc02b1dc0 100644 --- a/packages/components/xpub-submit/src/components/DecisionReviewColumn.js +++ b/packages/components/xpub-submit/src/components/DecisionReviewColumn.js @@ -48,7 +48,13 @@ const DecisionReviewColumn = ({ {manuscript.reviews && ( <Section id="accordion.review"> <Accordion - Component={<ReviewAccordion reviews={manuscript.reviews} />} + Component={ + <ReviewAccordion + reviews={manuscript.reviews.filter( + review => !review.isDecision, + )} + /> + } key="review" title="Reviews" /> diff --git a/packages/components/xpub-submit/src/components/FormTemplate.js b/packages/components/xpub-submit/src/components/FormTemplate.js index f96589ee76375f0191b15e1bed6fd0e78bccaeb0..805cec6ca0530f3cbd3ca47c18ba50e105c7bc21 100644 --- a/packages/components/xpub-submit/src/components/FormTemplate.js +++ b/packages/components/xpub-submit/src/components/FormTemplate.js @@ -1,15 +1,14 @@ import React from 'react' import styled from 'styled-components' import { th } from '@pubsweet/ui-toolkit' -import { unescape, groupBy, isArray, get, cloneDeep } from 'lodash' +import { unescape, groupBy, isArray, get, set, cloneDeep } from 'lodash' import { FieldArray } from 'formik' -import ReactHtmlParser from 'react-html-parser' import * as elements from '@pubsweet/ui' import * as validators from 'xpub-validators' import { AbstractEditor } from 'xpub-edit' import { Heading1, Section, Legend, SubNote } from '../styles' import AuthorsInput from './AuthorsInput' -// import Notes from './Notes' +import Supplementary from './Supplementary' import Confirm from './Confirm' const Wrapper = styled.div` @@ -55,49 +54,31 @@ const stripHtml = htmlString => { const filterFileManuscript = files => files.filter( file => - file.type === 'manuscript' && + file.fileType === 'manuscript' && file.mimeType !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) -const { - ValidatedFieldFormik, - Button, - Attachment, - UploadingFile, - FileUploadList, -} = elements +const { ValidatedFieldFormik, Button, Attachment } = elements + elements.AbstractEditor = ({ validationStatus, setTouched, onChange, value, + values, ...rest }) => ( <AbstractEditor - value={value || ''} + value={get(values, rest.name) || ''} {...rest} onChange={val => { - setTouched(createObject(rest.name, true)) + setTouched(set({}, rest.name, true)) onChange(stripHtml(val)) }} /> ) -elements.SupplementaryFiles = props => ( - <FileUploadList - {...props} - buttonText="↑ Upload files" - FileComponent={UploadingFile} - files={cloneDeep(props.value) - .map(val => { - val.name = val.filename - return val - }) - .filter(val => val.type === 'supplementary')} - /> -) - elements.AuthorsInput = AuthorsInput const rejectProps = (obj, keys) => @@ -138,19 +119,6 @@ const composeValidate = (vld = [], valueField = {}) => value => { return errors.length > 0 ? errors[0] : undefined } -const createObject = (key, value) => { - const obj = {} - const parts = key.split('.') - if (parts.length === 1) { - obj[parts[0]] = value - } else if (parts.length > 1) { - // concat all but the first part of the key - const remainingParts = parts.slice(1, parts.length).join('.') - obj[parts[0]] = createObject(remainingParts, value) - } - return obj -} - const groupElements = elements => { const grouped = groupBy(elements, n => n.group || 'default') @@ -174,12 +142,11 @@ const groupElements = elements => { return startArr } -const renderArray = ( - elementsComponentArray, - setFieldValue, - setTouched, - onChange, -) => ({ form: { values }, name }) => +const renderArray = (elementsComponentArray, onChange) => ({ + form: { values, setTouched }, + replace, + name, +}) => get(values, name).map((elValues, index) => { const element = elementsComponentArray.find(elv => Object.values(elValues).includes(elv.type), @@ -201,17 +168,25 @@ const renderArray = ( 'validateValue', 'description', 'order', + 'value', ])} component={elements[element.component]} key={`notes-validate-${element.id}`} - name={`${name}.${index}.${element.name}`} + name={`${name}.${index}.content`} onChange={value => { - setFieldValue(`${name}.[${index}].${element.name}`, value, true) - onChange(value, `${name}.${index}.${element.name}`) + const data = { + notesType: element.type, + content: value, + } + replace(index, data, `${name}.[${index}]`, true) + const notes = cloneDeep(values) + set(notes, `${name}.[${index}]`, data) + onChange(notes.meta.notes, `${name}`) }} readonly={false} setTouched={setTouched} validate={composeValidate(element.validate, element.validateValue)} + values={values} /> <SubNote dangerouslySetInnerHTML={createMarkup(element.description)} /> </Section> @@ -220,18 +195,12 @@ const renderArray = ( const ElementComponentArray = ({ elementsComponentArray, - setFieldValue, - setTouched, onChange, + uploadFile, }) => ( <FieldArray name={elementsComponentArray[0].group} - render={renderArray( - elementsComponentArray, - setFieldValue, - setTouched, - onChange, - )} + render={renderArray(elementsComponentArray, onChange)} /> ) @@ -243,23 +212,24 @@ export default ({ confirming, manuscript, setTouched, + values, setFieldValue, uploadFile, + createFile, onChange, onSubmit, + ...props }) => ( <Wrapper> <Heading1>{form.name}</Heading1> - <Intro> - <div> - {ReactHtmlParser( - (form.description || '').replace( - '###link###', - link(journal, manuscript), - ), - )} - </div> - </Intro> + <Intro + dangerouslySetInnerHTML={createMarkup( + (form.description || '').replace( + '###link###', + link(journal, manuscript), + ), + )} + /> <form onSubmit={handleSubmit}> {groupElements(form.children || []).map(element => !isArray(element) ? ( @@ -268,37 +238,47 @@ export default ({ key={`${element.id}`} > <Legend dangerouslySetInnerHTML={createMarkup(element.title)} /> - {element.component === 'AuthorsInput' && <AuthorsInput />} - {element.component !== 'AuthorsInput' && ( - <ValidatedFieldFormik - component={elements[element.component]} - key={`validate-${element.id}`} - name={element.name} - onChange={value => { - const val = value.target ? value.target.value : value - setFieldValue(element.name, val, true) - onChange(val, element.name) - }} - readonly={false} - setTouched={setTouched} - {...rejectProps(element, [ - 'component', - 'title', - 'sectioncss', - 'parse', - 'format', - 'validate', - 'validateValue', - 'description', - 'order', - ])} + {element.component === 'SupplementaryFiles' && ( + <Supplementary + createFile={createFile} + onChange={onChange} uploadFile={uploadFile} - validate={composeValidate( - element.validate, - element.validateValue, - )} /> )} + {element.component === 'AuthorsInput' && ( + <AuthorsInput onChange={onChange} /> + )} + {element.component !== 'AuthorsInput' && + element.component !== 'SupplementaryFiles' && ( + <ValidatedFieldFormik + component={elements[element.component]} + key={`validate-${element.id}`} + name={element.name} + onChange={value => { + const val = value.target ? value.target.value : value + setFieldValue(element.name, val, true) + onChange(val, element.name) + }} + readonly={false} + setTouched={setTouched} + {...rejectProps(element, [ + 'component', + 'title', + 'sectioncss', + 'parse', + 'format', + 'validate', + 'validateValue', + 'description', + 'order', + ])} + validate={composeValidate( + element.validate, + element.validateValue, + )} + values={values} + /> + )} <SubNote dangerouslySetInnerHTML={createMarkup(element.description)} /> @@ -313,24 +293,24 @@ export default ({ ), )} - {filterFileManuscript(manuscript.files).length > 0 ? ( + {filterFileManuscript(values.files || []).length > 0 ? ( <Section id="files.manuscript"> <Legend space>Submitted Manuscript</Legend> <Attachment - file={filesToAttachment(filterFileManuscript(manuscript.files)[0])} - key={filterFileManuscript(manuscript.files)[0].url} + file={filesToAttachment(filterFileManuscript(values.files)[0])} + key={filterFileManuscript(values.files)[0].url} uploaded /> </Section> ) : null} - {!manuscript.status === 'submitted' && form.haspopup === 'false' && ( + {values.status !== 'submitted' && form.haspopup === 'false' && ( <Button primary type="submit"> Submit your manuscript </Button> )} - {!manuscript.status !== 'submitted' && form.haspopup === 'true' && ( + {values.status !== 'submitted' && form.haspopup === 'true' && ( <div> <Button onClick={toggleConfirming} primary type="button"> Submit your manuscript diff --git a/packages/components/xpub-submit/src/components/Submit.js b/packages/components/xpub-submit/src/components/Submit.js index e229de568efdab0042346f46122ddf024b55facf..b11a1578f2afc404b10bc25e8c9c464bc86e1d12 100644 --- a/packages/components/xpub-submit/src/components/Submit.js +++ b/packages/components/xpub-submit/src/components/Submit.js @@ -36,8 +36,8 @@ const SubmittedVersionColumns = props => ( const Submit = ({ journal, manuscript, forms, ...formProps }) => { const decisionSections = [] - - manuscript.manuscriptVersions.forEach(versionElem => { + const manuscriptVersions = manuscript.manuscriptVersions || [] + manuscriptVersions.forEach(versionElem => { const submittedMoment = moment(versionElem.submitted) const label = submittedMoment.format('YYYY-MM-DD') decisionSections.push({ diff --git a/packages/components/xpub-submit/src/components/SubmitPage.js b/packages/components/xpub-submit/src/components/SubmitPage.js index bed527c01d98a7cae8f92d472b5964d433ca2b5a..b3c2a695bdfa0de53a3c918617d90563f19e6e09 100644 --- a/packages/components/xpub-submit/src/components/SubmitPage.js +++ b/packages/components/xpub-submit/src/components/SubmitPage.js @@ -1,4 +1,4 @@ -import { throttle, cloneDeep, isEmpty } from 'lodash' +import { throttle, cloneDeep, isEmpty, set } from 'lodash' import { compose, withProps, withState, withHandlers } from 'recompose' import { graphql } from 'react-apollo' import { gql } from 'apollo-client-preset' @@ -14,8 +14,8 @@ const fragmentFields = ` created label filename + fileType mimeType - type size url } @@ -23,37 +23,24 @@ const fragmentFields = ` open recommendation created + isDecision comments { content } user { - identities { - ... on Local { - name { - surname - } - } - } + id + username } } teams { id role members { - status - user { - id - username - identities { - ... on Local { - name { - surname - } - } - } - } + id + username } } + decision status meta { title @@ -73,8 +60,6 @@ const fragmentFields = ` date } notes { - id - created notesType content } @@ -129,66 +114,26 @@ const updateMutation = gql` const uploadSuplementaryFilesMutation = gql` mutation($file: Upload!) { upload(file: $file) { + url + } + } +` + +const createFileMutation = gql` + mutation($file: Upload!) { + createFile(file: $file) { id created - filename label - size + filename + fileType mimeType + size url } } ` -const omitSpecialKeysDeep = object => { - const output = {} - - Object.entries(object).forEach(([key, value]) => { - if (!key.startsWith('_')) { - if ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) { - output[key] = omitSpecialKeysDeep(value) - } else { - output[key] = value - } - } - }) - - return output -} - -const createObject = (key, value) => { - const obj = {} - const parts = key.split('.') - if (parts.length === 1) { - obj[parts[0]] = value - } else if (parts.length > 1) { - // concat all but the first part of the key - const remainingParts = parts.slice(1, parts.length).join('.') - obj[parts[0]] = createObject(remainingParts, value) - } - return obj -} - -// const stripNullsDeep = object => { -// const output = {} - -// Object.entries(object).forEach(([key, value]) => { -// if (value !== null) { -// if (typeof value === 'object' && !Array.isArray(value)) { -// output[key] = stripNullsDeep(value) -// } else { -// output[key] = value -// } -// } -// }) - -// return output -// } - export default compose( graphql(query, { options: ({ match }) => ({ @@ -199,58 +144,46 @@ export default compose( }), props: ({ data }) => ({ data }), }), - graphql(uploadSuplementaryFilesMutation, { + graphql(createFileMutation, { props: ({ mutate, ownProps }) => ({ - uploadFile: file => { + createFile: value => { + const file = { + url: value.url, + filename: value.filename, + mimeType: value.mimeType, + size: value.size, + fileType: 'supplementary', + object: 'Manuscript', + objectId: ownProps.match.params.version, + } + mutate({ variables: { file, }, - update: (proxy, { data: { upload } }) => { - const { manuscript } = cloneDeep(ownProps.data) - manuscript.files.push( - Object.assign({}, { ...upload }, { type: 'supplementary' }), - ) - proxy.writeQuery({ - query: gql` - query($id: ID!) { - manuscript(id: $id) { - ${fragmentFields} - } - } - `, - variables: { - id: ownProps.match.params.version, - }, - data: { manuscript }, - }) - }, }) }, }), }), + graphql(uploadSuplementaryFilesMutation, { + props: ({ mutate, ownProps }) => ({ + uploadFile: file => + mutate({ + variables: { + file, + }, + }), + }), + }), graphql(updateMutation, { props: ({ mutate, ownProps }) => { const updateManuscript = (value, path) => { + const input = {} + set(input, path, value) mutate({ variables: { id: ownProps.match.params.version, - input: JSON.stringify(createObject(path, value)), - }, - update: (proxy, { data: { updateManuscript } }) => { - proxy.writeQuery({ - query: gql` - query($id: ID!) { - manuscript(id: $id) { - ${fragmentFields} - } - } - `, - variables: { - id: ownProps.match.params.version, - }, - data: { manuscript: updateManuscript }, - }) + input: JSON.stringify(input), }, }) } @@ -264,27 +197,13 @@ export default compose( graphql(updateMutation, { props: ({ mutate, ownProps }) => ({ onSubmit: (manuscript, { history }) => { - const data = cloneDeep(manuscript) - data.status = 'submitted' + const updateManuscript = { + status: 'submitted', + } mutate({ variables: { id: ownProps.match.params.version, - input: JSON.stringify(data), - }, - update: (proxy, { data: { updateManuscript } }) => { - proxy.writeQuery({ - query: gql` - query($id: ID!) { - manuscript(id: $id) { - ${fragmentFields} - } - } - `, - variables: { - id: ownProps.match.params.version, - }, - data: { manuscript: data }, - }) + input: JSON.stringify(updateManuscript), }, }).then(() => { history.push('/') @@ -296,6 +215,7 @@ export default compose( withProps(({ getFile, manuscript, match: { params: { journal } } }) => ({ journal: { id: journal }, forms: cloneDeep(getFile), + manuscript, })), withFormik({ initialValues: {}, diff --git a/packages/components/xpub-submit/src/components/Supplementary.js b/packages/components/xpub-submit/src/components/Supplementary.js new file mode 100644 index 0000000000000000000000000000000000000000..e6ce5f04e612560d6e4892fe1e725804d4041757 --- /dev/null +++ b/packages/components/xpub-submit/src/components/Supplementary.js @@ -0,0 +1,52 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { FieldArray } from 'formik' +import { Flexbox, UploadButton, UploadingFile } from '@pubsweet/ui' + +const renderFilesUpload = (onChange, uploadFile, createFile) => ({ + form: { values, setFieldValue }, + push, + insert, +}) => [ + <UploadButton + buttonText="↑ Upload files" + onChange={event => { + const fileArray = Array.from(event.target.files).map(file => { + const fileUpload = { + fileType: 'supplementary', + filename: file.name, + } + return fileUpload + }) + setFieldValue('files', fileArray.concat(values.files)) + Array.from(event.target.files).forEach(file => { + uploadFile(file).then(({ data }) => { + const newFile = { + url: data.upload.url, + filename: file.name, + mimeType: file.type, + size: file.size, + } + createFile(newFile) + }) + }) + }} + />, + <Flexbox> + {cloneDeep(values.files || []) + .filter(val => val.fileType === 'supplementary') + .map(val => { + val.name = val.filename + return <UploadingFile file={val} key={val.name} uploaded /> + })} + </Flexbox>, +] + +const Supplementary = ({ onChange, uploadFile, createFile }) => ( + <FieldArray + name="files" + render={renderFilesUpload(onChange, uploadFile, createFile)} + /> +) + +export default Supplementary diff --git a/packages/components/xpub-submit/src/components/atoms/Icon.js b/packages/components/xpub-submit/src/components/atoms/Icon.js new file mode 100644 index 0000000000000000000000000000000000000000..be73f6787298853af0995b343e9c99f306edde52 --- /dev/null +++ b/packages/components/xpub-submit/src/components/atoms/Icon.js @@ -0,0 +1,24 @@ +import React from 'react' +import { withTheme } from 'styled-components' +import { get, has } from 'lodash' +import * as reactFeatherIcons from 'react-feather' + +const Icon = ({ iconName, overrideName, className, theme, ...props }) => { + const isOverrideInTheme = has(theme.icons, overrideName) + if (isOverrideInTheme) { + const OverrideIcon = get(theme.icons, overrideName) + return <OverrideIcon className={className} {...props} /> + } + const isIconInDefaultSet = reactFeatherIcons[iconName] + // TODO: conversation with Pubsweet - what should we default to when + // there's no obvious react-feather equivalent? + if (!isIconInDefaultSet) { + // eslint-disable-next-line no-console + console.warn("Icon '%s' not found", iconName) + return null + } + const DefaultIcon = reactFeatherIcons[iconName] + return <DefaultIcon className={className} {...props} /> +} + +export default withTheme(Icon) diff --git a/packages/components/xpub-teams-manager/package.json b/packages/components/xpub-teams-manager/package.json index 54a666ef450ab9e24d6578e5de8d678c07184cbe..47bb19f2a54ce1b60bceb6fca3d6c0e8fa3dc05c 100644 --- a/packages/components/xpub-teams-manager/package.json +++ b/packages/components/xpub-teams-manager/package.json @@ -15,7 +15,6 @@ "lodash": "^4.17.11", "prop-types": "^15.5.10", "react-dom": "^16.2.0", - "react-redux": "^5.0.6", "recompose": "^0.26.0", "redux": "^3.7.2", "styled-components": "^4.1.1", @@ -30,8 +29,11 @@ "faker": "^4.1.0" }, "peerDependencies": { + "apollo-client-preset": "^1.0.8", "config": "^3.0.1", + "graphql-tag": "^2.10.0", "pubsweet-client": ">=2.1.0", - "react": ">=16" + "react": ">=16", + "react-apollo": "^2.3.3" } } diff --git a/packages/components/xpub-teams-manager/src/components/Team.jsx b/packages/components/xpub-teams-manager/src/components/Team.jsx index 8a58d08ece46252dfe0020304195aada7db676c1..56a7bb510c5f5d080687dec801337446751f4203 100644 --- a/packages/components/xpub-teams-manager/src/components/Team.jsx +++ b/packages/components/xpub-teams-manager/src/components/Team.jsx @@ -15,7 +15,7 @@ const Team = ({ team, number, userOptions, deleteTeam, updateTeam }) => {team.name} {team.teamType.permissions} </TeamTableCell>, <TeamTableCell> - {team.object.type} {team.object.id} + {team.object.objectType} {team.object.objectId} </TeamTableCell>, <TeamTableCell width={40}> <StyledMenu @@ -24,7 +24,7 @@ const Team = ({ team, number, userOptions, deleteTeam, updateTeam }) => name="members" onChange={members => updateTeam(members, team)} options={userOptions} - value={team.members} + value={team.members.map(member => member.id)} /> </TeamTableCell>, <TeamTableCell width={15}> diff --git a/packages/components/xpub-teams-manager/src/components/TeamCreator.jsx b/packages/components/xpub-teams-manager/src/components/TeamCreator.jsx index 68bfa26d1474aae6442ea5bf095789df1ea09f64..ad777546fe3eee623264d0366ad8054c91bde138 100644 --- a/packages/components/xpub-teams-manager/src/components/TeamCreator.jsx +++ b/packages/components/xpub-teams-manager/src/components/TeamCreator.jsx @@ -5,10 +5,10 @@ import { Button, Menu } from '@pubsweet/ui' const TeamCreator = ({ teamTypeSelected, - collectionSelected, - collectionsOptions, + manuscriptSelected, + manuscriptsOptions, typesOptions, - onChangeCollection, + onChangeManuscript, onChangeType, onSave, }) => ( @@ -23,14 +23,14 @@ const TeamCreator = ({ reset={teamTypeSelected} value={teamTypeSelected} /> - <h4>Collection</h4> + <h4>Manuscript</h4> <Menu name="collection" - onChange={onChangeCollection} - options={collectionsOptions} + onChange={onChangeManuscript} + options={manuscriptsOptions} required - reset={collectionSelected} - value={collectionSelected} + reset={manuscriptSelected} + value={manuscriptSelected} /> <Button primary type="submit"> Create @@ -39,20 +39,20 @@ const TeamCreator = ({ ) export default compose( - withState('collectionSelected', 'onCollectionSelect', false), + withState('manuscriptSelected', 'onManuscriptSelect', false), withState('teamTypeSelected', 'onTeamTypeSelect', false), withHandlers({ - onChangeCollection: ({ onCollectionSelect }) => collectionId => - onCollectionSelect(() => collectionId || false), + onChangeManuscript: ({ onManuscriptSelect }) => collectionId => + onManuscriptSelect(() => collectionId || false), onChangeType: ({ onTeamTypeSelect }) => teamType => onTeamTypeSelect(() => teamType || false), onSave: ({ teamTypeSelected, - collectionSelected, + manuscriptSelected, create, typesOptions, onTeamTypeSelect, - onCollectionSelect, + onManuscriptSelect, }) => event => { event.preventDefault() const teamType = teamTypeSelected @@ -60,9 +60,9 @@ export default compose( let objectId let objectType - if (collectionSelected) { - objectId = collectionSelected - objectType = 'collection' + if (manuscriptSelected) { + objectId = manuscriptSelected + objectType = 'Manuscript' } if (teamType && objectId && objectType) { @@ -70,14 +70,14 @@ export default compose( name: find(typesOptions, types => types.value === teamType).label, teamType, object: { - id: objectId, - type: objectType, + objectId, + objectType, }, members: [], }) onTeamTypeSelect(() => true) - onCollectionSelect(() => true) + onManuscriptSelect(() => true) } }, }), diff --git a/packages/components/xpub-teams-manager/src/components/TeamsManager.jsx b/packages/components/xpub-teams-manager/src/components/TeamsManager.jsx index 698bcaaaca60777406f480845cbae84a0f2776d0..64b4a82a13c9285fa0d23ff61da59cc3ccc700a4 100644 --- a/packages/components/xpub-teams-manager/src/components/TeamsManager.jsx +++ b/packages/components/xpub-teams-manager/src/components/TeamsManager.jsx @@ -11,7 +11,7 @@ const TeamsManager = ({ createTeam, error, userOptions, - collectionsOptions, + manuscriptsOptions, typesOptions, }) => ( <Page> @@ -37,8 +37,8 @@ const TeamsManager = ({ </TeamTable> )} <TeamCreator - collectionsOptions={collectionsOptions} create={createTeam} + manuscriptsOptions={manuscriptsOptions} typesOptions={typesOptions} /> </Page> diff --git a/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.integration.test.js b/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.integration.test.js index 48d2fbb53b8aab60c69a5cd0151e6e7fdc3c5c88..9f8297208b06efe805b63e48d759b96560e0dba6 100644 --- a/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.integration.test.js +++ b/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.integration.test.js @@ -1,15 +1,13 @@ import React from 'react' +import faker from 'faker' import { MemoryRouter } from 'react-router-dom' -import { Provider } from 'react-redux' -import { combineReducers } from 'redux' -import configureMockStore from 'redux-mock-store' -import thunk from 'redux-thunk' +import { MockedProvider } from 'react-apollo/test-utils' + import Enzyme, { mount } from 'enzyme' import Adapter from 'enzyme-adapter-react-16' import { ThemeProvider } from 'styled-components' - -import { reducers } from 'pubsweet-client' +import queries from './graphql/queries' import TeamsManagerPage from './TeamsManagerPage' @@ -59,29 +57,24 @@ global.window.localStorage = { getItem: jest.fn(() => 'tok123'), } -const reducer = combineReducers(reducers) - -const middlewares = [thunk] -const mockStore = () => - configureMockStore(middlewares)(actions => - Object.assign( - actions.reduce(reducer, { - currentUser: { isAuthenticated: true }, - }), - (actions.users || []).reduce(reducer, { - users: { - users: [ - { id: '1', username: 'author' }, - { id: '2', username: 'managing Editor' }, - ], - }, - }), - ), - ) +const mocks = [ + { + request: { + query: queries.teamManager, + }, + result: { + data: { + currentUser: { id: faker.random.uuid(), username: 'test', admin: true }, + teams: [], + users: [], + manuscripts: [], + }, + }, + }, +] describe('TeamsManagerPage', () => { it('runs', done => { - const store = mockStore() const page = mount( <MemoryRouter> <ThemeProvider @@ -90,9 +83,9 @@ describe('TeamsManagerPage', () => { colorSecondary: '#E7E7E7', }} > - <Provider store={store}> + <MockedProvider addTypename={false} mocks={mocks}> <TeamsManagerPage /> - </Provider> + </MockedProvider> </ThemeProvider> </MemoryRouter>, ) diff --git a/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.js b/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.js index a7cea464d47677fca85356a6a8df6b36a1dd65bb..f1e7d5cc1838bb7dbef42300e03a08fb54e31f00 100644 --- a/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.js +++ b/packages/components/xpub-teams-manager/src/components/TeamsManagerPage.js @@ -1,46 +1,162 @@ import { compose } from 'recompose' -import { connect } from 'react-redux' -import { actions } from 'pubsweet-client' +import { cloneDeep, omit } from 'lodash' import config from 'config' -import { ConnectPage } from 'xpub-connect' +import { graphql } from 'react-apollo' +import { gql } from 'apollo-client-preset' +import queries from './graphql/queries' import TeamsManager from './TeamsManager' +const deleteTeamMutation = gql` + mutation($id: ID) { + deleteTeam(id: $id) { + id + type + teamType + name + object { + objectId + objectType + } + members { + id + username + } + } + } +` + +const createTeamMutation = gql` + mutation($input: TeamInput!) { + createTeam(input: $input) { + id + type + teamType + name + object { + objectId + objectType + } + members { + id + username + } + } + } +` + +const updateTeamMutation = gql` + mutation($id: ID, $input: TeamInput) { + updateTeam(id: $id, input: $input) { + id + type + teamType + name + object { + objectId + objectType + } + members { + id + username + } + } + } +` + export default compose( - ConnectPage(() => [ - actions.getCollections(), - actions.getTeams(), - actions.getUsers(), - ]), - connect( - state => { - const { collections, teams, error } = state - const { users } = state.users - - const userOptions = users.map(user => ({ + graphql(queries.teamManager, { + props: ({ data }) => { + const userOptions = ((data || {}).users || []).map(user => ({ value: user.id, label: user.username, })) - const collectionsOptions = collections.map(collection => ({ - value: collection.id, - label: collection.title, + const manuscriptsOptions = ((data || {}).manuscripts || []).map(manu => ({ + value: manu.id, + label: manu.meta.title, })) + const types = config.authsome.teams const typesOptions = Object.keys(types).map(type => ({ value: type, label: `${types[type].name} ${types[type].permissions}`, })) + return { + teams: (data || {}).teams, + manuscriptsOptions, + userOptions, + typesOptions, + } + }, + }), + graphql(updateTeamMutation, { + props: ({ mutate }) => { + const updateTeam = (members, team) => { + const data = cloneDeep(team) + const input = omit(data, ['id', 'object.__typename', '__typename']) + + input.members = members + mutate({ + variables: { + id: team.id, + input, + }, + }) + } - return { teams, collectionsOptions, userOptions, typesOptions, error } + return { + updateTeam, + } }, - (dispatch, { history }) => ({ - deleteTeam: collection => dispatch(actions.deleteTeam(collection)), - updateTeam: (members, team) => { - team = Object.assign(team, { members }) - return dispatch(actions.updateTeam(team)) + }), + graphql(deleteTeamMutation, { + props: ({ mutate }) => { + const deleteTeam = data => { + mutate({ + variables: { + id: data.id, + }, + }) + } + + return { + deleteTeam, + } + }, + options: { + update: (proxy, { data: { deleteTeam } }) => { + const data = proxy.readQuery({ query: queries.teamManager }) + const teamsIndex = data.teams.findIndex( + team => team.id === deleteTeam.id, + ) + if (teamsIndex > -1) { + data.teams.splice(teamsIndex, 1) + proxy.writeQuery({ query: queries.teamManager, data }) + } + }, + }, + }), + graphql(createTeamMutation, { + props: ({ mutate }) => { + const createTeam = input => { + mutate({ + variables: { + input, + }, + }) + } + + return { + createTeam, + } + }, + options: { + update: (proxy, { data: { createTeam } }) => { + const data = proxy.readQuery({ query: queries.teamManager }) + data.teams.push(createTeam) + proxy.writeQuery({ query: queries.teamManager, data }) }, - createTeam: team => dispatch(actions.createTeam(team)), - }), - ), + }, + }), )(TeamsManager) diff --git a/packages/components/xpub-teams-manager/src/components/graphql/queries/index.js b/packages/components/xpub-teams-manager/src/components/graphql/queries/index.js new file mode 100644 index 0000000000000000000000000000000000000000..3e78e3cf1cf032c1299824c02a60d1e6f755dd72 --- /dev/null +++ b/packages/components/xpub-teams-manager/src/components/graphql/queries/index.js @@ -0,0 +1,41 @@ +import gql from 'graphql-tag' + +const fragmentFields = ` + id + created + meta { + title + } +` + +export default { + teamManager: gql` + query { + teams { + id + teamType + name + object { + objectId + objectType + } + members { + id + } + } + + users { + id + username + admin + } + + manuscripts { + ${fragmentFields} + manuscriptVersions { + ${fragmentFields} + } + } + } + `, +} diff --git a/yarn.lock b/yarn.lock index 0c0381f5f4f8d6cdc548ccc498a184894ce5a18e..32e5fb2b48261469682e67f8cacf25edf1d19f60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3792,6 +3792,15 @@ classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5, classnames@^2.2.6: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== +clean-tag@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/clean-tag/-/clean-tag-1.1.0.tgz#83d0d3650d84805f7edeeda15a23d773069bf242" + integrity sha512-ly8usTlnKRFrL+D3+vZrX7lsDV+T9prxdL+IxLzjhKRIUYJlMWC4R3mUABWjb8AW33Wijy6UT2UUD2vYjPRF7A== + dependencies: + html-tags "^2.0.0" + react ">=16.0.0" + styled-system ">=2.0.0 || >=3.0.0" + clean-webpack-plugin@^0.1.19: version "0.1.19" resolved "https://registry.yarnpkg.com/clean-webpack-plugin/-/clean-webpack-plugin-0.1.19.tgz#ceda8bb96b00fe168e9b080272960d20fdcadd6d" @@ -7312,6 +7321,14 @@ graphql@^14.0.2: dependencies: iterall "^1.2.2" +grid-styled@^4.1.0: + version "4.3.3" + resolved "https://registry.yarnpkg.com/grid-styled/-/grid-styled-4.3.3.tgz#d3356e2ed5b5bb8433c0cd32e729e2a0d2ddcd03" + integrity sha512-aTwrgBrVGHimYIbHzC6fVxWhCjng/7OgHtU+CUq2vLeDk1Yq1EF35Ca3NUjFE3TVFZjAQ1505TqHXirgkUtFyA== + dependencies: + clean-tag "^1.0.4" + system-components "^2.2.3" + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -13010,7 +13027,7 @@ react-dropdown@^1.6.2: dependencies: classnames "^2.2.3" -react-dropzone@^4.1.2, react-dropzone@^4.2.7: +react-dropzone@^4.1.2, react-dropzone@^4.2.7, react-dropzone@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.3.0.tgz#facdd7db16509772633c9f5200621ac01aa6706f" integrity sha512-ULfrLaTSsd8BDa9KVAGCueuq1AN3L14dtMsGGqtP0UwYyjG4Vhf158f/ITSHuSPYkZXbvfcIiOlZsH+e3QWm+Q== @@ -13028,7 +13045,7 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-feather@^1.0.8: +react-feather@^1.0.8, react-feather@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-1.1.5.tgz#f7f9384c17d2d061b5b8298f46efc0e497f48469" integrity sha512-hAPWatSFnhTNp9Ub96B7LMgOnWzXonA/LxqC2ANfUuc57jJocuWyO96yow2flUUDpitodh9mf6iOZzkyGYmAng== @@ -13259,7 +13276,7 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.0: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" -react@^16.2.0: +react@>=16.0.0, react@^16.2.0: version "16.7.0" resolved "https://registry.yarnpkg.com/react/-/react-16.7.0.tgz#b674ec396b0a5715873b350446f7ea0802ab6381" integrity sha512-StCz3QY8lxTb5cl2HJxjwLFOXPIFQp+p+hxQfc8WE0QiLfCtIlKj8/+5tjjKm8uSTlAW+fCPaavGFS06V9Ar3A== @@ -15049,6 +15066,21 @@ styled-normalize@^8.0.4: resolved "https://registry.yarnpkg.com/styled-normalize/-/styled-normalize-8.0.4.tgz#6a0885dc16c61d88813dab8f5137da928fe0c947" integrity sha512-dXkKPnT+JcpqYnS0iQiBhUCOdheDz9xEm3H+ZARJV7l4WCXmRkwCx20ujkIYfESoYaiefbuX+F+rbtF6Ku5kvA== +"styled-system@>=2.0.0 || >=3.0.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/styled-system/-/styled-system-3.2.0.tgz#db08036035d6c5b8818ba7bccaf125d8711ecd9a" + integrity sha512-T/jDWstf++Bx0DFTnUHLMxkhnGZoJhZZO+bERGy5L/9uPnAiKthOZ1XxXcEaNrybM6PATMD/42rHIK+qpIVv+w== + dependencies: + "@babel/runtime" "^7.1.2" + prop-types "^15.6.2" + +styled-system@^2.3.1: + version "2.3.6" + resolved "https://registry.yarnpkg.com/styled-system/-/styled-system-2.3.6.tgz#a38c1ffa04a5c35adec46473984e463c48b16f7c" + integrity sha512-lGAh/8tC70f5hBUD7w0UOWCKyOBK2AzzWKu9BGzqla/Yjx8PzrvaciA7uATbm493hXTfRrecSdLdrIUET5IYnA== + dependencies: + prop-types "^15.6.0" + stylelint-config-prettier@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/stylelint-config-prettier/-/stylelint-config-prettier-2.1.0.tgz#395874225ceef02ea8e31c2f4073098f4505b054" @@ -15255,6 +15287,14 @@ symbol-tree@^3.2.2: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= +system-components@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/system-components/-/system-components-2.2.3.tgz#3248f7c80affa4b9b61003ecd719a73da4462e59" + integrity sha512-zKcZEgqQEOGCTjug1L8Pkbw/ADJ3dI8iuwN6X1/+LLkE7PyCikjHckDFVtSYDLNgV1cjMlIlDPU17tngQoib9Q== + dependencies: + clean-tag "^1.0.4" + styled-system "^2.3.1" + table@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36"