From 361776c244573ef982abf44ac0bfad6865656488 Mon Sep 17 00:00:00 2001 From: Jure Triglav <juretriglav@gmail.com> Date: Thu, 1 Oct 2020 15:03:21 +0200 Subject: [PATCH] feat: add visual abstract --- .../src/components/config/Elements.js | 9 ++ .../component-frontpage/src/Frontpage.js | 23 ++- .../component-frontpage/src/queries.js | 1 + .../component-frontpage/src/style.js | 84 ++--------- .../src/components/FormTemplate.js | 13 +- .../component-submit/src/components/Submit.js | 6 +- app/components/component-submit/src/style.js | 4 +- app/components/shared/FilesUpload.js | 135 +++++++++++++----- app/components/shared/UploadingFile.js | 13 +- app/storage/forms/submit.json | 25 ++++ app/theme/elements/GlobalStyle.js | 2 +- config/permissions.js | 78 +++++++++- server/model-file/src/resolvers.js | 4 + server/model-file/src/typeDefs.js | 1 + 14 files changed, 267 insertions(+), 131 deletions(-) diff --git a/app/components/component-formbuilder/src/components/config/Elements.js b/app/components/component-formbuilder/src/components/config/Elements.js index 6e087d0c19..f722753466 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 7c9b6437ff..388379bd2b 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 a6ae1970d3..d8df9e0eb9 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 d92c9154fe..9126c7cf30 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 60ed9ba313..a4e76b2a10 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 ef930fc4b2..9dc446ee27 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 a690702187..7151a65131 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 b4405cd9a7..998064ffd7 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 5c7e9ab16c..cbf42e815a 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 f5036936a3..513654aaaf 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 a632718b05..9e227bcef3 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 236ab7d55f..c377a7c20e 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 5001766ded..e81be86c02 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 d9d84614fc..73e440b61f 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 { -- GitLab