Browse Source

Feature: Mobile - Add forward article action

Vladimir Sheremet 2 years ago
parent
commit
160ae1d7e1

+ 1 - 1
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleBubble.vue

@@ -193,7 +193,7 @@ const previewImage = (event: Event, attachment: TicketArticleAttachment) => {
           :key="attachment.internalId"
           :file="attachment"
           :download-url="attachment.downloadUrl"
-          :preview-url="attachment.previewUrl"
+          :preview-url="attachment.preview"
           :no-preview="!$c.ui_ticket_zoom_attachments_preview"
           :wrapper-class="colorsClasses.file"
           :icon-class="colorsClasses.icon"

+ 12 - 19
app/frontend/apps/mobile/pages/ticket/composable/useArticleAttachments.ts

@@ -1,10 +1,10 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
 import { useApplicationStore } from '@shared/stores/application'
-import { canDownloadFile } from '@shared/utils/files'
 import type { ComputedRef } from 'vue'
 import { computed } from 'vue'
 import type { TicketArticleAttachment } from '@shared/entities/ticket/types'
+import { getArticleAttachmentsLinks } from '@shared/entities/ticket-article/composables/getArticleAttachmentsLinks'
 
 interface AttachmentsOptions {
   ticketInternalId: number
@@ -14,30 +14,23 @@ interface AttachmentsOptions {
 
 export const useArticleAttachments = (options: AttachmentsOptions) => {
   const application = useApplicationStore()
-  const buildBaseUrl = (attachment: TicketArticleAttachment) => {
-    const { ticketInternalId, articleInternalId } = options
-    const apiUrl = application.config.api_path as string
-    return `${apiUrl}/ticket_attachment/${ticketInternalId}/${articleInternalId}/${attachment.internalId}`
-  }
-  const buildPreviewUrl = (baseUrl: string) => `${baseUrl}?view=preview`
-  const canDownloadAttachment = (attachment: TicketArticleAttachment) => {
-    return canDownloadFile(attachment.type)
-  }
-  const buildDownloadUrl = (baseUrl: string, canDownload: boolean) => {
-    const dispositionParams = canDownload ? '?disposition=attachment' : ''
-    return `${baseUrl}${dispositionParams}`
-  }
 
   const attachments = computed(() => {
     return options.attachments.value.map((attachment) => {
-      const baseUrl = buildBaseUrl(attachment)
-      const previewUrl = buildPreviewUrl(baseUrl)
-      const canDownload = canDownloadAttachment(attachment)
-      const downloadUrl = buildDownloadUrl(baseUrl, canDownload)
+      const { previewUrl, canDownload, downloadUrl } =
+        getArticleAttachmentsLinks(
+          {
+            ticketInternalId: options.ticketInternalId,
+            articleInternalId: options.articleInternalId,
+            internalId: attachment.internalId,
+            type: attachment.type,
+          },
+          application.config,
+        )
 
       return {
         ...attachment,
-        previewUrl,
+        preview: previewUrl,
         canDownload,
         downloadUrl,
       }

+ 1 - 0
app/frontend/apps/mobile/pages/ticket/composable/useTicketArticleContext.ts

@@ -111,6 +111,7 @@ export const useTicketArticleContext = () => {
         link,
         onAction: () =>
           perform(ticket, article, {
+            formId: form.value?.formId || '',
             selection: selectionData.value,
             openReplyDialog,
             getNewArticleBody,

+ 32 - 24
app/frontend/apps/mobile/pages/ticket/composable/useTicketEdit.ts

@@ -2,7 +2,6 @@
 
 import type { ComputedRef, ShallowRef } from 'vue'
 import { computed, reactive, watch } from 'vue'
-import { pick } from 'lodash-es'
 import type { FormValues, FormRef, FormData } from '@shared/components/Form'
 import { useObjectAttributeFormData } from '@shared/entities/object-attributes/composables/useObjectAttributeFormData'
 import { useObjectAttributes } from '@shared/entities/object-attributes/composables/useObjectAttributes'
@@ -10,24 +9,17 @@ import type { TicketUpdateInput } from '@shared/graphql/types'
 import { EnumObjectManagerObjects } from '@shared/graphql/types'
 import { MutationHandler } from '@shared/server/apollo/handler'
 import type { TicketById } from '@shared/entities/ticket/types'
-import type { FileUploaded } from '@shared/components/Form/fields/FieldFile/types'
-import type { SecurityValue } from '@shared/components/Form/fields/FieldSecurity/types'
+import type { TicketArticleFormValues } from '@shared/entities/ticket-article/action/plugins/types'
 import { getNode } from '@formkit/core'
+import type { PartialRequired } from '@shared/types/utils'
+import { convertFilesToAttachmentInput } from '@shared/utils/files'
 import { useTicketUpdateMutation } from '../graphql/mutations/update.api'
 
-interface ArticleFormValues {
-  articleType: string
-  body: string
-  internal: boolean
-  cc?: string[]
-  subtype?: string
-  inReplyTo?: string
-  to?: string[]
-  subject?: string
-  attachments?: FileUploaded[]
-  contentType?: string
-  security?: SecurityValue
-}
+type TicketArticleReceivedFormValues = PartialRequired<
+  TicketArticleFormValues,
+  // form always has these values
+  'articleType' | 'body' | 'internal'
+>
 
 export const useTicketEdit = (
   ticket: ComputedRef<TicketById | undefined>,
@@ -64,17 +56,31 @@ export const useTicketEdit = (
 
   const processArticle = (
     formId: string,
-    article: ArticleFormValues | undefined,
+    article: TicketArticleReceivedFormValues | undefined,
   ) => {
     if (!article) return null
 
-    const attachments = article.attachments || []
-    const files = attachments.map((file) =>
-      pick(file, ['content', 'name', 'type']),
-    )
-
     const contentType = getNode('body')?.context?.contentType || 'text/html'
 
+    if (contentType === 'text/html') {
+      const body = document.createElement('div')
+      body.innerHTML = article.body
+      // TODO: https://github.com/zammad/coordination-feature-mobile-view/issues/396
+      // prosemirror always adds a visible linebreak inside an empty paragraph,
+      // but it doesn't return it inside a schema, so we need to add it manually
+      body.querySelectorAll('p').forEach((p) => {
+        p.removeAttribute('data-marker')
+        if (
+          p.childNodes.length === 0 ||
+          p.lastChild?.nodeType !== Node.TEXT_NODE ||
+          p.textContent?.endsWith('\n')
+        ) {
+          p.appendChild(document.createElement('br'))
+        }
+      })
+      article.body = body.innerHTML
+    }
+
     return {
       type: article.articleType,
       body: article.body,
@@ -85,7 +91,7 @@ export const useTicketEdit = (
       subtype: article.subtype,
       inReplyTo: article.inReplyTo,
       contentType,
-      attachments: attachments.length ? { files, formId } : null,
+      attachments: convertFilesToAttachmentInput(formId, article.attachments),
       security: article.security,
     }
   }
@@ -100,7 +106,9 @@ export const useTicketEdit = (
     const { internalObjectAttributeValues, additionalObjectAttributeValues } =
       useObjectAttributeFormData(ticketObjectAttributesLookup.value, formData)
 
-    const formArticle = formData.article as ArticleFormValues | undefined
+    const formArticle = formData.article as
+      | TicketArticleReceivedFormValues
+      | undefined
     const article = processArticle(formData.formId, formArticle)
 
     return mutationUpdate.send({

+ 5 - 7
app/frontend/apps/mobile/pages/ticket/views/TicketCreate.vue

@@ -43,6 +43,7 @@ import { errorOptions } from '@mobile/router/error'
 import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import { useTicketSignature } from '@shared/composables/useTicketSignature'
 import { TicketFormData } from '@shared/entities/ticket/types'
+import { convertFilesToAttachmentInput } from '@shared/utils/files'
 import { useTicketCreateMutation } from '../graphql/mutations/create.api'
 
 const router = useRouter()
@@ -320,13 +321,10 @@ const createTicket = async (formData: FormData<TicketFormData>) => {
   } as TicketCreateInput
 
   if (formData.attachments && input.article) {
-    input.article.attachments = {
-      files: formData.attachments?.map((file) => ({
-        name: file.name,
-        type: file.type,
-      })),
-      formId: formData.formId,
-    }
+    input.article.attachments = convertFilesToAttachmentInput(
+      formData.formId,
+      formData.attachments,
+    )
   }
 
   return ticketCreateMutation

+ 4 - 0
app/frontend/apps/mobile/styles/main.scss

@@ -2,6 +2,10 @@
 
 .Content,
 .ProseMirror {
+  div[data-signature-marker] {
+    display: none;
+  }
+
   &:focus-visible {
     outline: none;
   }

+ 27 - 19
app/frontend/cypress/shared/components/Form/fields/FieldEditor/editor-signature.cy.ts

@@ -4,6 +4,8 @@ import { getNode } from '@formkit/core'
 import type { FieldEditorContext } from '@shared/components/Form/fields/FieldEditor/types'
 import { mountEditor } from './utils'
 
+const html = String.raw
+
 const getContext = () =>
   getNode('editor')?.context as FieldEditorContext | undefined
 
@@ -93,10 +95,14 @@ describe('correctly adds signature', () => {
           })
       })
   })
-  it('add signature when there is a full quote there', () => {
-    const quote = '<blockquote data-full="true"><p>Some Quote</p></blockquote>'
+  it('add signature before marker', () => {
+    const originalBody = html`<p data-marker="signature-before"></p>
+      <blockquote type="cite">
+        <p>Subject: Welcome to Zammad!</p>
+      </blockquote>`
+
     mountEditor({
-      value: `<p></p>${quote}`,
+      value: originalBody,
     })
 
     cy.findByRole('textbox')
@@ -106,22 +112,24 @@ describe('correctly adds signature', () => {
           body: SIGNATURE,
           id: 3,
         })
-        cy.findByRole('textbox')
-          .should(
-            'have.html',
-            `${BREAK_HTML}${WRAPPED_SIGNATURE(
-              '3',
-              `${PARSED_SIGNATURE}${BREAK_HTML}`,
-            )}${quote}`,
-          )
-          .type('new')
-          .should('include.html', `<p>new</p><div data-signature`) // cursor didn't move
-          .then(() => {
-            context.removeSignature()
-            cy.findByRole('textbox')
-              .should('include.html', `<p>new</p>`)
-              .and('include.html', quote)
-          })
       })
+
+    cy.findByRole('textbox')
+      .should('contain.html', `${BREAK_HTML}<div data-signature=`)
+      .should(
+        'contain.html',
+        '<p data-marker="signature-before"><br class="ProseMirror-trailingBreak"></p><blockquote ',
+      )
+      .type('text')
+      .should('contain.html', '<p>text</p><div data-signature')
+      .then(resolveContext)
+      .then((context) => {
+        context.removeSignature()
+      })
+
+    cy.findByRole('textbox').should(
+      'contain.html',
+      `<p>text</p><p data-marker=`,
+    )
   })
 })

+ 1 - 0
app/frontend/shared/components/Form/Form.vue

@@ -506,6 +506,7 @@ const resetForm = (
 
 defineExpose({
   formNode,
+  formId,
   resetForm,
 })
 

+ 5 - 0
app/frontend/shared/components/Form/__tests__/useForm.spec.ts

@@ -48,6 +48,7 @@ describe('useForm', () => {
     const { form, node } = useForm()
 
     form.value = {
+      formId: 'test-form',
       formNode: getNode('test-form') as FormKitNode,
       resetForm: vi.fn(),
     }
@@ -67,6 +68,7 @@ describe('useForm', () => {
       useForm()
 
     form.value = {
+      formId: 'test-form',
       formNode: getNode('test-form') as FormKitNode,
       resetForm: vi.fn(),
     }
@@ -85,6 +87,7 @@ describe('useForm', () => {
     const formNode = getNode('test-form') as FormKitNode
 
     form.value = {
+      formId: 'test-form',
       formNode,
       resetForm: vi.fn(),
     }
@@ -106,6 +109,7 @@ describe('useForm', () => {
     const { form, values } = useForm()
 
     form.value = {
+      formId: 'test-form',
       formNode: getNode('test-form') as FormKitNode,
       resetForm: vi.fn(),
     }
@@ -121,6 +125,7 @@ describe('useForm', () => {
     const { form, formSubmit } = useForm()
 
     form.value = {
+      formId: 'test-form',
       formNode: getNode('test-form') as FormKitNode,
       resetForm: vi.fn(),
     }

+ 28 - 11
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorInput.vue

@@ -61,6 +61,8 @@ getCustomExtensions(reactiveContext).forEach((extension) => {
 })
 
 const showActionBar = ref(false)
+const editorValue = ref<string>(VITE_TEST_MODE ? props.context._value : '')
+
 const editor = useEditor({
   extensions: editorExtensions,
   editorProps: {
@@ -69,6 +71,7 @@ const editor = useEditor({
       name: props.context.node.name,
       id: props.context.id,
       class: 'min-h-[80px]',
+      'data-value': editorValue.value,
     },
     // add inlined files
     handlePaste(view, event) {
@@ -104,6 +107,9 @@ const editor = useEditor({
       contentType.value === 'text/plain' ? editor.getText() : editor.getHTML()
     const value = content === '<p></p>' ? '' : content
     props.context.node.input(value)
+
+    if (!VITE_TEST_MODE) return
+    editorValue.value = value
   },
   onFocus() {
     showActionBar.value = true
@@ -114,8 +120,8 @@ const editor = useEditor({
 })
 
 watch(
-  () => props.context.id,
-  (id) => {
+  () => [props.context.id, editorValue.value],
+  ([id, value]) => {
     editor.value?.setOptions({
       editorProps: {
         attributes: {
@@ -123,6 +129,7 @@ watch(
           name: props.context.node.name,
           id,
           class: 'min-h-[80px]',
+          'data-value': value,
         },
       },
     })
@@ -169,15 +176,18 @@ useEventListener('click', (e) => {
 const resolveSignaturePosition = (editor: Editor) => {
   let blockquotePosition: number | null = null
   editor.state.doc.descendants((node, pos) => {
-    if (node.type.name === 'blockquote' && node.attrs['data-full']) {
+    if (
+      (node.type.name === 'paragraph' || node.type.name === 'blockquote') &&
+      node.attrs['data-marker'] === 'signature-before'
+    ) {
       blockquotePosition = pos
       return false
     }
   })
   if (blockquotePosition !== null) {
-    return { position: 'top', from: blockquotePosition }
+    return { position: 'before', from: blockquotePosition }
   }
-  return { position: 'bottom', from: editor.state.doc.content.size || 0 }
+  return { position: 'after', from: editor.state.doc.content.size || 0 }
 }
 
 const addSignature = (signature: PossibleSignature) => {
@@ -190,13 +200,20 @@ const addSignature = (signature: PossibleSignature) => {
   editor.value.commands.removeSignature()
   const { position, from } = resolveSignaturePosition(editor.value)
   editor.value.commands.addSignature({ ...signature, position, from })
+  const getNewPosition = (editor: Editor) => {
+    if (signature.position != null) {
+      return signature.position
+    }
+    if (currentPosition < from) {
+      return currentPosition
+    }
+    if (from === 0 && currentPosition <= 1) {
+      return 1
+    }
+    return editor.state.doc.content.size - positionFromEnd
+  }
   // calculate new position from the end of the signature otherwise
-  editor.value.commands.focus(
-    signature.position ??
-      (currentPosition < from
-        ? currentPosition
-        : editor.value.state.doc.content.size - positionFromEnd),
-  )
+  editor.value.commands.focus(getNewPosition(editor.value))
   requestAnimationFrame(() => {
     testFlags.set('editor.signatureAdd')
   })

Some files were not shown because too many files changed in this diff