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