Browse Source

Feature: Mobile - Add reply article action

Vladimir Sheremet 2 years ago
parent
commit
5559188b2f

+ 4 - 0
.rubocop/default.yml

@@ -355,6 +355,10 @@ Layout/MultilineMethodCallIndentation:
 Lint/UnusedMethodArgument:
 Lint/UnusedMethodArgument:
   AllowUnusedKeywordArguments: true
   AllowUnusedKeywordArguments: true
 
 
+Naming/PredicateName:
+  Exclude:
+    - "app/graphql/gql/types/**/*"
+
 RSpec/DescribeMethod:
 RSpec/DescribeMethod:
   # We want to be able to split tests into subfolders, where several cases are grouped together.
   # We want to be able to split tests into subfolders, where several cases are grouped together.
   Description: 'Checks that the second argument to the top level describe is the tested method name.'
   Description: 'Checks that the second argument to the top level describe is the tested method name.'

+ 5 - 0
app/frontend/apps/mobile/pages/ticket/__tests__/mocks/detail-view.ts

@@ -85,6 +85,11 @@ export const defaultTicket = () =>
         __typename: 'Group',
         __typename: 'Group',
         id: convertToGraphQLId('Group', 1),
         id: convertToGraphQLId('Group', 1),
         name: 'Users',
         name: 'Users',
+        emailAddress: {
+          __typename: 'EmailAddress',
+          name: 'zammad',
+          emailAddress: 'zammad@example.com',
+        },
       },
       },
       priority: {
       priority: {
         __typename: 'TicketPriority',
         __typename: 'TicketPriority',

+ 32 - 9
app/frontend/apps/mobile/pages/ticket/composable/useTicketArticleContext.ts

@@ -6,13 +6,19 @@ import { computed, nextTick, ref, shallowRef } from 'vue'
 import type { TicketArticle, TicketById } from '@shared/entities/ticket/types'
 import type { TicketArticle, TicketById } from '@shared/entities/ticket/types'
 import { createArticleActions } from '@shared/entities/ticket-article/action/plugins'
 import { createArticleActions } from '@shared/entities/ticket-article/action/plugins'
 import type { TicketArticlePerformOptions } from '@shared/entities/ticket-article/action/plugins/types'
 import type { TicketArticlePerformOptions } from '@shared/entities/ticket-article/action/plugins/types'
-import type { EditorContentType } from '@shared/components/Form/fields/FieldEditor/types'
+import type {
+  EditorContentType,
+  FieldEditorContext,
+} from '@shared/components/Form/fields/FieldEditor/types'
+import { getArticleSelection } from '@shared/entities/ticket-article/composables/getArticleSelection'
+import type { SelectionData } from '@shared/utils/selection'
+import type { FormKitNode } from '@formkit/core'
 import { useTicketInformation } from './useTicketInformation'
 import { useTicketInformation } from './useTicketInformation'
 
 
 export const useTicketArticleContext = () => {
 export const useTicketArticleContext = () => {
   const articleForContext = shallowRef<TicketArticle>()
   const articleForContext = shallowRef<TicketArticle>()
   const ticketForContext = shallowRef<TicketById>()
   const ticketForContext = shallowRef<TicketById>()
-  const selectionRange = ref<Range>()
+  const selectionData = ref<SelectionData>()
   const metadataDialog = useDialog({
   const metadataDialog = useDialog({
     name: 'article-metadata',
     name: 'article-metadata',
     component: () =>
     component: () =>
@@ -34,14 +40,18 @@ export const useTicketArticleContext = () => {
 
 
   const openReplyDialog: TicketArticlePerformOptions['openReplyDialog'] =
   const openReplyDialog: TicketArticlePerformOptions['openReplyDialog'] =
     async (values = {}) => {
     async (values = {}) => {
-      const formNode = form.value?.formNode
-      if (!formNode) return
+      const formNode = form.value?.formNode as FormKitNode
 
 
       await showArticleReplyDialog()
       await showArticleReplyDialog()
 
 
       const { articleType, ...otherOptions } = values
       const { articleType, ...otherOptions } = values
 
 
-      formNode.find('articleType')?.input(articleType, false)
+      const typeNode = formNode.find('articleType', 'name')
+      if (formNode.context) {
+        Object.assign(formNode.context, { _open: true })
+      }
+
+      typeNode?.input(articleType, false)
       // trigger new fields that depend on the articleType
       // trigger new fields that depend on the articleType
       await nextTick()
       await nextTick()
 
 
@@ -56,6 +66,20 @@ export const useTicketArticleContext = () => {
           node.emit('prop:options', options)
           node.emit('prop:options', options)
         }
         }
       }
       }
+
+      formNode.emit('article-reply-open', articleType)
+
+      const context = formNode.find('body', 'name')?.context as
+        | FieldEditorContext
+        | undefined
+
+      context?.focus()
+
+      nextTick(() => {
+        if (formNode.context) {
+          Object.assign(formNode.context, { _open: false })
+        }
+      })
     }
     }
 
 
   const getNewArticleBody = (type: EditorContentType): string => {
   const getNewArticleBody = (type: EditorContentType): string => {
@@ -87,7 +111,7 @@ export const useTicketArticleContext = () => {
         link,
         link,
         onAction: () =>
         onAction: () =>
           perform(ticket, article, {
           perform(ticket, article, {
-            selection: selectionRange.value,
+            selection: selectionData.value,
             openReplyDialog,
             openReplyDialog,
             getNewArticleBody,
             getNewArticleBody,
           }),
           }),
@@ -129,11 +153,10 @@ export const useTicketArticleContext = () => {
     articleForContext.value = article
     articleForContext.value = article
     ticketForContext.value = ticket
     ticketForContext.value = ticket
     try {
     try {
-      // TODO: only put range, if it's inside the article
       // can throw RangeError
       // can throw RangeError
-      selectionRange.value = window.getSelection()?.getRangeAt(0)
+      selectionData.value = getArticleSelection(article.internalId)
     } catch {
     } catch {
-      selectionRange.value = undefined
+      selectionData.value = undefined
     }
     }
   }
   }
 
 

+ 2 - 0
app/frontend/apps/mobile/pages/ticket/composable/useTicketEdit.ts

@@ -20,6 +20,7 @@ interface ArticleFormValues {
   body: string
   body: string
   internal: boolean
   internal: boolean
   cc?: string[]
   cc?: string[]
+  subtype?: string
   inReplyTo?: string
   inReplyTo?: string
   to?: string[]
   to?: string[]
   subject?: string
   subject?: string
@@ -81,6 +82,7 @@ export const useTicketEdit = (
       cc: article.cc,
       cc: article.cc,
       to: article.to,
       to: article.to,
       subject: article.subject,
       subject: article.subject,
+      subtype: article.subtype,
       inReplyTo: article.inReplyTo,
       inReplyTo: article.inReplyTo,
       contentType,
       contentType,
       attachments: attachments.length ? { files, formId } : null,
       attachments: attachments.length ? { files, formId } : null,

+ 42 - 7
app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts

@@ -13,6 +13,8 @@ import type {
   ChangedField,
   ChangedField,
   ReactiveFormSchemData,
   ReactiveFormSchemData,
 } from '@shared/components/Form/types'
 } from '@shared/components/Form/types'
+import type { FieldEditorContext } from '@shared/components/Form/fields/FieldEditor/types'
+import type { FormKitNode } from '@formkit/core'
 
 
 export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
 export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
   const ticketArticleTypes = computed(() => {
   const ticketArticleTypes = computed(() => {
@@ -66,6 +68,11 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
         type: 'hidden',
         type: 'hidden',
         name: 'inReplyTo',
         name: 'inReplyTo',
       },
       },
+      {
+        if: '$fns.includes($currentArticleType.attributes, "subtype")',
+        type: 'hidden',
+        name: 'subtype',
+      },
       {
       {
         name: 'articleType',
         name: 'articleType',
         label: __('Article Type'),
         label: __('Article Type'),
@@ -132,7 +139,7 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
         triggerFormUpdater: false,
         triggerFormUpdater: false,
       },
       },
       {
       {
-        if: '$fns.includes($currentArticleType.attributes, "security")',
+        if: '$smimeIntegration === true && $fns.includes($currentArticleType.attributes, "security")',
         name: 'security',
         name: 'security',
         label: __('Security'),
         label: __('Security'),
         type: 'security',
         type: 'security',
@@ -189,7 +196,7 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
     },
     },
   ]
   ]
 
 
-  const articleTypeHandler = () => {
+  const articleTypeChangeHandler = () => {
     const executeHandler = (
     const executeHandler = (
       execution: FormHandlerExecution,
       execution: FormHandlerExecution,
       schemaData: ReactiveFormSchemData,
       schemaData: ReactiveFormSchemData,
@@ -215,11 +222,19 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
       schemaData,
       schemaData,
       changedField,
       changedField,
     ) => {
     ) => {
-      if (!executeHandler(execution, schemaData, changedField) || !ticket.value)
+      if (
+        !executeHandler(execution, schemaData, changedField) ||
+        !ticket.value ||
+        !formNode
+      )
         return
         return
+      const body = formNode.find('body', 'name')
+      const context = {
+        body: body?.context as unknown as FieldEditorContext,
+      }
 
 
       if (changedField?.newValue !== changedField?.oldValue) {
       if (changedField?.newValue !== changedField?.oldValue) {
-        currentArticleType.value?.onDeselected?.(ticket.value)
+        currentArticleType.value?.onDeselected?.(ticket.value, context)
       }
       }
 
 
       const newType = ticketArticleTypes.value.find(
       const newType = ticketArticleTypes.value.find(
@@ -228,10 +243,12 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
 
 
       if (!newType) return
       if (!newType) return
 
 
-      newType.onSelected?.(ticket.value)
+      if (!formNode.context?._open) {
+        newType.onSelected?.(ticket.value, context)
+      }
       currentArticleType.value = newType
       currentArticleType.value = newType
 
 
-      formNode?.find('internal')?.input(newType.internal, false)
+      formNode.find('internal')?.input(newType.internal, false)
     }
     }
 
 
     return {
     return {
@@ -243,9 +260,27 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
     }
     }
   }
   }
 
 
+  const articleTypeSelectHandler = (formNode: FormKitNode) => {
+    // this is called only when user replied to an article, but the type inside form did not change
+    // (because dialog was opened before, and type was changed then, but we still need to trigger select, because visually it's what happens)
+    formNode.on('article-reply-open', ({ payload }) => {
+      if (!payload || !ticket.value) return
+      const articleType = ticketArticleTypes.value.find(
+        (type) => type.value === payload,
+      )
+      if (!articleType) return
+      const body = formNode.find('body', 'name') as FormKitNode
+      const context = {
+        body: body.context as unknown as FieldEditorContext,
+      }
+      articleType.onOpened?.(ticket.value, context)
+    })
+  }
+
   return {
   return {
     ticketEditSchema,
     ticketEditSchema,
     currentArticleType,
     currentArticleType,
-    articleTypeHandler,
+    articleTypeHandler: articleTypeChangeHandler,
+    articleTypeSelectHandler,
   }
   }
 }
 }

+ 9 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.api.ts

@@ -10,6 +10,7 @@ export const TicketArticleAttributesFragmentDoc = gql`
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   messageId
   messageId
@@ -18,6 +19,7 @@ export const TicketArticleAttributesFragmentDoc = gql`
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   cc {
   cc {
@@ -25,6 +27,7 @@ export const TicketArticleAttributesFragmentDoc = gql`
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   subject
   subject
@@ -33,6 +36,7 @@ export const TicketArticleAttributesFragmentDoc = gql`
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   messageId
   messageId
@@ -51,11 +55,16 @@ export const TicketArticleAttributesFragmentDoc = gql`
   bodyWithUrls
   bodyWithUrls
   internal
   internal
   createdAt
   createdAt
+  originBy {
+    id
+    fullname
+  }
   createdBy {
   createdBy {
     id
     id
     fullname
     fullname
     firstname
     firstname
     lastname
     lastname
+    email
     authorizations {
     authorizations {
       provider
       provider
       uid
       uid

+ 9 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.graphql

@@ -6,6 +6,7 @@ fragment ticketArticleAttributes on TicketArticle {
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   messageId
   messageId
@@ -14,6 +15,7 @@ fragment ticketArticleAttributes on TicketArticle {
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   cc {
   cc {
@@ -21,6 +23,7 @@ fragment ticketArticleAttributes on TicketArticle {
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   subject
   subject
@@ -29,6 +32,7 @@ fragment ticketArticleAttributes on TicketArticle {
     parsed {
     parsed {
       name
       name
       emailAddress
       emailAddress
+      isSystemAddress
     }
     }
   }
   }
   messageId
   messageId
@@ -47,11 +51,16 @@ fragment ticketArticleAttributes on TicketArticle {
   bodyWithUrls
   bodyWithUrls
   internal
   internal
   createdAt
   createdAt
+  originBy {
+    id
+    fullname
+  }
   createdBy {
   createdBy {
     id
     id
     fullname
     fullname
     firstname
     firstname
     lastname
     lastname
+    email
     authorizations {
     authorizations {
       provider
       provider
       uid
       uid

+ 5 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts

@@ -24,6 +24,7 @@ export const TicketAttributesFragmentDoc = gql`
     lastname
     lastname
     fullname
     fullname
     image
     image
+    email
     organization {
     organization {
       id
       id
       internalId
       internalId
@@ -53,6 +54,10 @@ export const TicketAttributesFragmentDoc = gql`
   group {
   group {
     id
     id
     name
     name
+    emailAddress {
+      name
+      emailAddress
+    }
   }
   }
   priority {
   priority {
     id
     id

+ 5 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql

@@ -19,6 +19,7 @@ fragment ticketAttributes on Ticket {
     lastname
     lastname
     fullname
     fullname
     image
     image
+    email
     organization {
     organization {
       id
       id
       internalId
       internalId
@@ -48,6 +49,10 @@ fragment ticketAttributes on Ticket {
   group {
   group {
     id
     id
     name
     name
+    emailAddress {
+      name
+      emailAddress
+    }
   }
   }
   priority {
   priority {
     id
     id

+ 15 - 2
app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue

@@ -22,6 +22,7 @@ import {
 } from '@shared/components/CommonNotifications'
 } from '@shared/components/CommonNotifications'
 import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import { convertToGraphQLId } from '@shared/graphql/utils'
 import { convertToGraphQLId } from '@shared/graphql/utils'
+import { useApplicationStore } from '@shared/stores/application'
 import { useTicketEdit } from '../composable/useTicketEdit'
 import { useTicketEdit } from '../composable/useTicketEdit'
 import { TICKET_INFORMATION_SYMBOL } from '../composable/useTicketInformation'
 import { TICKET_INFORMATION_SYMBOL } from '../composable/useTicketInformation'
 import { useTicketQuery } from '../graphql/queries/ticket.api'
 import { useTicketQuery } from '../graphql/queries/ticket.api'
@@ -85,8 +86,12 @@ const {
   openArticleReplyDialog,
   openArticleReplyDialog,
 } = useTicketArticleReply(ticket, form)
 } = useTicketArticleReply(ticket, form)
 
 
-const { currentArticleType, ticketEditSchema, articleTypeHandler } =
-  useTicketEditForm(ticket)
+const {
+  currentArticleType,
+  ticketEditSchema,
+  articleTypeHandler,
+  articleTypeSelectHandler,
+} = useTicketEditForm(ticket)
 
 
 const { notify } = useNotifications()
 const { notify } = useNotifications()
 
 
@@ -168,8 +173,15 @@ onBeforeRouteLeave(async () => {
   return confirmed
   return confirmed
 })
 })
 
 
+const application = useApplicationStore()
+
+const smimeIntegration = computed(
+  () => (application.config.smime_integration as boolean) ?? false,
+)
+
 const ticketEditSchemaData = reactive({
 const ticketEditSchemaData = reactive({
   formLocation,
   formLocation,
+  smimeIntegration,
   newTicketArticleRequested,
   newTicketArticleRequested,
   newTicketArticlePresent,
   newTicketArticlePresent,
   currentArticleType,
   currentArticleType,
@@ -193,6 +205,7 @@ const ticketEditSchemaData = reactive({
         :schema="ticketEditSchema"
         :schema="ticketEditSchema"
         :flatten-form-groups="['ticket']"
         :flatten-form-groups="['ticket']"
         :handlers="[articleTypeHandler()]"
         :handlers="[articleTypeHandler()]"
+        :form-kit-plugins="[articleTypeSelectHandler]"
         :schema-data="ticketEditSchemaData"
         :schema-data="ticketEditSchemaData"
         :initial-values="initialTicketValue"
         :initial-values="initialTicketValue"
         :initial-entity-object="ticket"
         :initial-entity-object="ticket"

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