diff --git a/app/components/component-formbuilder/src/components/config/Elements.js b/app/components/component-formbuilder/src/components/config/Elements.js index 6e087d0c194ff79c67fdcd0cb39a67075a983d10..f72275346681838605aa5ebdc60afb9fec3162ed 100644 --- a/app/components/component-formbuilder/src/components/config/Elements.js +++ b/app/components/component-formbuilder/src/components/config/Elements.js @@ -81,6 +81,15 @@ const elements = { order: orderfield, validate, }, + VisualAbstract: { + id: textfield, + title: textfield, + name: textfield, + description: editorfield, + shortDescription: textfield, + order: orderfield, + validate, + }, AuthorsInput: { id: textfield, title: textfield, diff --git a/app/components/component-frontpage/src/Frontpage.js b/app/components/component-frontpage/src/Frontpage.js index 7c9b6437ffc2613433eb4b6de0d259ad83ba35c2..388379bd2bb525b208852df50dd69c900c6feca0 100644 --- a/app/components/component-frontpage/src/Frontpage.js +++ b/app/components/component-frontpage/src/Frontpage.js @@ -2,7 +2,7 @@ import React, { useContext } from 'react' import { useQuery } from '@apollo/client' import { JournalContext } from '../../xpub-journal/src' import queries from './queries' -import { Container, Placeholder } from './style' +import { Container, Placeholder, VisualAbstract } from './style' import { Spinner, @@ -20,10 +20,14 @@ const Frontpage = ({ history, ...props }) => { if (loading) return <Spinner /> if (error) return JSON.stringify(error) - const frontpage = (data.publishedManuscripts?.manuscripts || []).map(m => ({ - ...m, - submission: JSON.parse(m.submission), - })) + const frontpage = (data.publishedManuscripts?.manuscripts || []).map(m => { + const visualAbstract = m.files?.find(f => f.fileType === 'visualAbstract') + return { + ...m, + visualAbstract: visualAbstract?.url, + submission: JSON.parse(m.submission), + } + }) return ( <Container> @@ -37,6 +41,15 @@ const Frontpage = ({ history, ...props }) => { <Title>{manuscript.meta.title}</Title> </SectionHeader> <SectionRow key={`manuscript-${manuscript.id}`}> + <p> + Visual abstract:{' '} + <VisualAbstract + alt="Visual abstract" + src={manuscript.visualAbstract} + /> + </p> + <p>Abstract: {manuscript.submission?.abstract}</p> + <p> {manuscript.submitter.defaultIdentity.name} ( {manuscript.submission.affiliation}) diff --git a/app/components/component-frontpage/src/queries.js b/app/components/component-frontpage/src/queries.js index a6ae1970d3d72971cde9e6b82eab117db72c10a0..d8df9e0eb9a6510f77dc7f26bf645920f16e3b79 100644 --- a/app/components/component-frontpage/src/queries.js +++ b/app/components/component-frontpage/src/queries.js @@ -26,6 +26,7 @@ export default { id url filename + fileType } meta { manuscriptId diff --git a/app/components/component-frontpage/src/style.js b/app/components/component-frontpage/src/style.js index d92c9154feff43f2eb5be4e95d909f3a34f44171..9126c7cf3064b80fab485a61c1c5e85852142bbe 100644 --- a/app/components/component-frontpage/src/style.js +++ b/app/components/component-frontpage/src/style.js @@ -1,88 +1,14 @@ import styled from 'styled-components' import { th, grid } from '@pubsweet/ui-toolkit' -export { Section, Content } from '../../shared' -const Actions = styled.div`` - export const Container = styled.div` background: ${th('colorBackgroundHue')}; padding: ${grid(2)}; + max-height: 100vh; min-height: 100vh; + overflow-y: scroll; }` -const ActionContainer = styled.div` - display: inline-block; -` - -export { Actions, ActionContainer } - -const Item = styled.div` - display: grid; - grid-template-columns: 1fr auto; - margin-bottom: calc(${th('gridUnit') * 4}); -` - -const Header = styled.div` - align-items: baseline; - display: flex; - justify-content: space-between; - text-transform: uppercase; -` - -const Body = styled.div` - align-items: space-between; - display: flex; - justify-content: space-between; - margin-bottom: calc(${th('gridUnit')} * 4); - padding-left: 1.5em; - & > div:last-child { - flex-shrink: 0; - } -` - -const Divider = styled.span.attrs(props => ({ - children: ` ${props.separator} `, -}))` - color: ${th('colorFurniture')}; - white-space: pre; -` - -export { Item, Header, Body, Divider } - -const Links = styled.div` - align-items: flex-end; - display: flex; - justify-content: bottom; -` - -const LinkContainer = styled.div` - font-size: ${th('fontSizeBaseSmall')}; - line-height: ${th('lineHeightBaseSmall')}; -` - -export { Links, LinkContainer } - -const Page = styled.div` - padding: ${grid(2)}; -` - -const Heading = styled.div` - color: ${th('colorPrimary')}; - font-family: ${th('fontReading')}; - font-size: ${th('fontSizeHeading3')}; - line-height: ${th('lineHeightHeading3')}; -` - -export { Page, Heading } - -export const HeadingWithAction = styled.div` - display: grid; - grid-template-columns: 1fr auto; - align-items: center; -` - -export { StatusBadge } from '../../shared' - export const Placeholder = styled.div` display: grid; place-items: center; @@ -90,3 +16,9 @@ export const Placeholder = styled.div` height: 100%; padding: 4em; ` + +export const VisualAbstract = styled.img` + max-width: ${grid(40)}; + max-height: ${grid(40)}; + display: block; +` diff --git a/app/components/component-submit/src/components/FormTemplate.js b/app/components/component-submit/src/components/FormTemplate.js index 60ed9ba3131945e3bfb13b0f50c4cd35677719d1..a4e76b2a108058f70f05219c9c9ff4c6ba0f39f7 100644 --- a/app/components/component-submit/src/components/FormTemplate.js +++ b/app/components/component-submit/src/components/FormTemplate.js @@ -274,11 +274,22 @@ export default ({ onChange={onChange} /> )} + {element.component === 'VisualAbstract' && ( + <FilesUpload + accept="image/*" + containerId={manuscript.id} + containerName="manuscript" + fileType="visualAbstract" + multiple={false} + onChange={onChange} + /> + )} {element.component === 'AuthorsInput' && ( <AuthorsInput data-testid={element.name} onChange={onChange} /> )} {element.component !== 'AuthorsInput' && - element.component !== 'SupplementaryFiles' && ( + element.component !== 'SupplementaryFiles' && + element.component !== 'VisualAbstract' && ( <ValidatedFieldFormik aria-label={element.placeholder || element.title} component={elements[element.component]} diff --git a/app/components/component-submit/src/components/Submit.js b/app/components/component-submit/src/components/Submit.js index ef930fc4b2107677d57e1c99a9c25478f2ceb5d4..9dc446ee27b148397bb6b39c61504c957226b0ea 100644 --- a/app/components/component-submit/src/components/Submit.js +++ b/app/components/component-submit/src/components/Submit.js @@ -7,7 +7,7 @@ import CreateANewVersion from './CreateANewVersion' import FormTemplate from './FormTemplate' import MessageContainer from '../../../component-chat/src' import { - Content, + SectionContent, VersionSwitcher, Tabs, Columns, @@ -77,7 +77,7 @@ const Submit = ({ }) decisionSection = { content: ( - <Content> + <SectionContent noGap> <Formik displayName="submit" // handleChange={props.handleChange} @@ -104,7 +104,7 @@ const Submit = ({ /> )} </Formik> - </Content> + </SectionContent> ), key: versionId, label: 'Edit submission info', diff --git a/app/components/component-submit/src/style.js b/app/components/component-submit/src/style.js index a6907021877c0597953c0b30b6d48c54798e1a0c..7151a65131604c807f8fd1e51770ec90bfc33543 100644 --- a/app/components/component-submit/src/style.js +++ b/app/components/component-submit/src/style.js @@ -5,8 +5,8 @@ export { Container, Content, Heading } from '../../shared' export const Heading1 = styled.h1` margin: 0 0 calc(${th('gridUnit')} * 3); - font-size: ${th('fontSizeHeading1')}; - line-height: ${th('lineHeightHeading1')}; + font-size: ${th('fontSizeHeading3')}; + line-height: ${th('lineHeightHeading3')}; ` export const Section = styled.div` diff --git a/app/components/shared/FilesUpload.js b/app/components/shared/FilesUpload.js index b4405cd9a70853c50ed0c2d043e596b628742e11..998064ffd7892708f0ee3e8143ba99e02b6bcd9b 100644 --- a/app/components/shared/FilesUpload.js +++ b/app/components/shared/FilesUpload.js @@ -32,6 +32,7 @@ const Message = styled.div` svg { margin-left: ${grid(1)}; } + color: ${props => (props.disabled ? th('colorTextPlaceholder') : 'inherit')}; ` const createFileMutation = gql` @@ -49,58 +50,108 @@ const createFileMutation = gql` } ` +const deleteFileMutation = gql` + mutation($id: ID!) { + deleteFile(id: $id) + } +` + const DropzoneAndList = ({ form: { values, setFieldValue }, push, insert, + remove, createFile, deleteFile, fileType, fieldName, -}) => ( - <> - <Dropzone - onDrop={async files => { - Array.from(files).forEach(async file => { - const data = await createFile(file) - push(data.createFile) - }) - }} - > - {({ getRootProps, getInputProps }) => ( - <Root {...getRootProps()} data-testid="dropzone"> - <input {...getInputProps()} /> - <Message> - Drag and drop your files here - <Icon color={theme.colorPrimary} inline> - file-plus - </Icon> - </Message> - </Root> - )} - </Dropzone> - <Files> - {cloneDeep(get(values, fieldName) || []) - .filter(val => (fileType ? val.fileType === fileType : true)) - .map(val => { - val.name = val.filename - return <UploadingFile file={val} key={val.name} uploaded /> - })} - </Files> - </> -) + multiple, + accept, +}) => { + // Disable the input in case we want a single file upload + // and a file has already been uploaded + const files = cloneDeep(get(values, fieldName) || []) + .map((file, index) => { + // This is so that we preserve the location of the file in the top-level + // files array (needed for deletion). + file.originalIndex = index + return file + }) + .filter(val => (fileType ? val.fileType === fileType : true)) + .map(val => { + val.name = val.filename + return val + }) + const disabled = !multiple && files.length + + return ( + <> + <Dropzone + accept={accept} + disabled={disabled} + multiple={multiple} + onDrop={async files => { + Array.from(files).forEach(async file => { + const data = await createFile(file) + push(data.createFile) + }) + }} + > + {({ getRootProps, getInputProps }) => ( + <Root {...getRootProps()} data-testid="dropzone"> + <input {...getInputProps()} /> + <Message disabled={disabled}> + {disabled ? ( + 'Your file has been uploaded.' + ) : ( + <> + Drag and drop your files here + <Icon color={theme.colorPrimary} inline> + file-plus + </Icon> + </> + )} + </Message> + </Root> + )} + </Dropzone> + <Files> + {files.map(file => ( + <UploadingFile + deleteFile={deleteFile} + file={file} + index={file.originalIndex} + key={file.name} + remove={remove} + uploaded + /> + ))} + </Files> + </> + ) +} const FilesUpload = ({ fileType, fieldName = 'files', containerId, containerName, initializeContainer, + multiple = true, + accept, }) => { - const [createFile] = useMutation(createFileMutation) - // const [deleteFile] = useMutation(deleteFileMutation) + const [createF] = useMutation(createFileMutation) + const [deleteF] = useMutation(deleteFileMutation, { + update(cache, { data: { deleteFile } }) { + const id = cache.identify({ + __typename: 'File', + id: deleteFile, + }) + cache.evict({ id }) + }, + }) - const createFileWithMeta = async file => { + const createFile = async file => { const meta = { filename: file.name, mimeType: file.type, @@ -113,7 +164,7 @@ const FilesUpload = ({ meta[`${containerName}Id`] = localContainerId - const { data } = await createFile({ + const { data } = await createF({ variables: { file, meta, @@ -122,15 +173,23 @@ const FilesUpload = ({ return data } + const deleteFile = async (file, index, remove) => { + const { data } = await deleteF({ variables: { id: file.id } }) + remove(index) + return data + } + return ( <FieldArray name={fieldName} render={formikProps => ( <DropzoneAndList - createFile={createFileWithMeta} - // deleteFile={deleteFile} + accept={accept} + createFile={createFile} + deleteFile={deleteFile} fieldName={fieldName} fileType={fileType} + multiple={multiple} {...formikProps} /> )} diff --git a/app/components/shared/UploadingFile.js b/app/components/shared/UploadingFile.js index 5c7e9ab16caf1ec5577f84b795e08e79d493c559..cbf42e815aea06128d1739dc75da52eff04d4f4a 100644 --- a/app/components/shared/UploadingFile.js +++ b/app/components/shared/UploadingFile.js @@ -1,5 +1,6 @@ import React from 'react' import styled from 'styled-components' +import { Action } from '@pubsweet/ui' import { th, grid } from '@pubsweet/ui-toolkit' const Icon = styled.div` @@ -91,7 +92,15 @@ const ErrorWrapper = styled.div` const getFileExtension = ({ name }) => name.replace(/^.+\./, '') -const UploadingFile = ({ file, progress, error, uploaded }) => { +const UploadingFile = ({ + file, + progress, + error, + deleteFile, + uploaded, + index, + remove, +}) => { const Root = uploaded ? Uploaded : Uploading const extension = getFileExtension(file) @@ -114,6 +123,8 @@ const UploadingFile = ({ file, progress, error, uploaded }) => { file.name )} </Filename> + + <Action onClick={() => deleteFile(file, index, remove)}>Remove</Action> </Root> ) } diff --git a/app/storage/forms/submit.json b/app/storage/forms/submit.json index f5036936a3338d223c35cd982d8568b7b5bd84db..513654aaaf50b1bdf9e43c517a8f8c333c8b8590 100644 --- a/app/storage/forms/submit.json +++ b/app/storage/forms/submit.json @@ -387,6 +387,31 @@ "placeholder": "Enter the manuscript's title", "description": "<p></p>", "order": "0" + }, + { + "title": "Visual Abstract", + "id": "1601471819978", + "component": "VisualAbstract", + "name": "visualAbstract", + "description": "<p>Provide a visual abstract or figure to represent your manuscript.</p>" + }, + { + "title": "Abstract", + "id": "1601488776604", + "component": "AbstractEditor", + "name": "submission.abstract", + "placeholder": "Input your abstract...", + "description": "<p>Please provide a short summary of your submission</p>", + "validate": [ + { + "value": "maxChars", + "label": "maximum Characters" + } + ], + "validateValue": { + "maxChars": "500" + }, + "shortDescription": "Abstract" } ], "description": "<p>Aperture is now accepting Research Object Submissions. Please fill out the form below to complete your submission.</p>", diff --git a/app/theme/elements/GlobalStyle.js b/app/theme/elements/GlobalStyle.js index a632718b05d0f6c452ee594f6af1414903d950b9..9e227bcef3c544d25b571104abc537ff3e2dfda6 100644 --- a/app/theme/elements/GlobalStyle.js +++ b/app/theme/elements/GlobalStyle.js @@ -47,7 +47,7 @@ a { color: ${th('colorPrimary')}; } -strong { +strong, b { font-weight: bold; } ` diff --git a/config/permissions.js b/config/permissions.js index 236ab7d55f3044dc5c93f6b4624e0763b2d44bf7..c377a7c20e4efc4a88a046ef803febac7bd39d50 100644 --- a/config/permissions.js +++ b/config/permissions.js @@ -53,7 +53,7 @@ const parent_manuscript_is_published = rule({ cache: 'contextual' })( }, ) -const review_is_by_current_user = rule({ cache: 'contextual' })( +const review_is_by_user = rule({ cache: 'contextual' })( async (parent, args, ctx, info) => { const rows = ctx.user && @@ -175,8 +175,73 @@ const user_is_author = rule({ cache: 'strict' })( }, ) +const user_is_author_of_files_associated_manuscript = rule({ + cache: 'no_cache', +})(async (parent, args, ctx, info) => { + let manuscriptId + if (args.meta && args.meta.manuscriptId) { + // Meta is supplied for createFile + // eslint-disable-next-line prefer-destructuring + manuscriptId = args.meta.manuscriptId + } else if (args.id) { + // id is supplied for deletion + const file = await ctx.models.File.query().findById(args.id) + // eslint-disable-next-line prefer-destructuring + manuscriptId = file.manuscriptId + } else { + return false + } + + const team = await ctx.models.Team.query() + .where({ + manuscriptId, + role: 'author', + }) + .first() + + if (!team) { + return false + } + const members = await team + .$relatedQuery('members') + .where('userId', ctx.user.id) + + if (members && members[0]) { + return true + } + + return false +}) +const user_is_author_of_the_manuscript_of_the_file = rule({ cache: 'strict' })( + async (parent, args, ctx, info) => { + const manuscript = await ctx.models.File.relatedQuery('manuscript') + .for(parent.id) + .first() + + const team = await ctx.models.Team.query() + .where({ + manuscriptId: manuscript.id, + role: 'author', + }) + .first() + + if (!team) { + return false + } + const members = await team + .$relatedQuery('members') + .where('userId', ctx.user.id) + + if (members && members[0]) { + return true + } + + return false + }, +) + // ¯\_(ツ)_/¯ -const current_user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete = rule( +const user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete = rule( { cache: 'strict', }, @@ -219,6 +284,7 @@ const permissions = { user: allow, }, Mutation: { + upload: isAuthenticated, createManuscript: isAuthenticated, updateManuscript: user_is_author, submitManuscript: user_is_author, @@ -233,6 +299,8 @@ const permissions = { user_is_editor_of_the_manuscript_of_the_review, ), createNewVersion: allow, + createFile: user_is_author_of_files_associated_manuscript, + deleteFile: user_is_author_of_files_associated_manuscript, }, Subscription: { messageCreated: userIsAllowedToChat, @@ -248,12 +316,14 @@ const permissions = { File: or( parent_manuscript_is_published, or( - current_user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete, + user_is_author_of_the_manuscript_of_the_file, + user_is_the_reviewer_of_the_manuscript_of_the_file_and_review_not_complete, userIsEditor, userIsAdmin, ), ), - Review: or(parent_manuscript_is_published, review_is_by_current_user), + UploadResult: allow, + Review: or(parent_manuscript_is_published, review_is_by_user), ReviewComment: allow, Channel: allow, Message: allow, diff --git a/server/model-file/src/resolvers.js b/server/model-file/src/resolvers.js index 5001766ded287034c90740b1662935484791cf5f..e81be86c02077ceef049fcd548354c2c45c22564 100644 --- a/server/model-file/src/resolvers.js +++ b/server/model-file/src/resolvers.js @@ -35,6 +35,10 @@ const resolvers = { return data }, + async deleteFile(_, { id }, ctx) { + await ctx.models.File.query().deleteById(id) + return id + }, }, } diff --git a/server/model-file/src/typeDefs.js b/server/model-file/src/typeDefs.js index d9d84614fc758b9025cb5b5423fbe8a5b0c85967..73e440b61f95fdec1e334a66337eb4d2e902502d 100644 --- a/server/model-file/src/typeDefs.js +++ b/server/model-file/src/typeDefs.js @@ -2,6 +2,7 @@ const typeDefs = ` extend type Mutation { # Using a separate variable because the Upload type hides other data createFile(file: Upload!, meta: FileMetaInput): File! + deleteFile(id: ID!): ID } input FileMetaInput {