Browse Source

Feature: Mobile - Add submitting for new article dialog

Vladimir Sheremet 2 years ago
parent
commit
7fcee65e10

+ 1 - 0
.eslintrc.js

@@ -33,6 +33,7 @@ module.exports = {
     'zammad/zammad-detect-translatable-string': 'error',
     'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
     'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+    'consistent-return': 'off', // allow implicit return
 
     // Loosen AirBnB's strict rules a bit to allow 'for .. of'
     'no-restricted-syntax': [

+ 8 - 3
app/frontend/apps/mobile/pages/ticket/__tests__/mocks/detail-view.ts

@@ -13,6 +13,7 @@ import { nullableMock, waitUntil } from '@tests/support/utils'
 import { TicketDocument } from '../../graphql/queries/ticket.api'
 
 import { TicketArticlesDocument } from '../../graphql/queries/ticket/articles.api'
+import { TicketArticleUpdatesDocument } from '../../graphql/subscriptions/ticketArticlesUpdates.api'
 import { TicketUpdatesDocument } from '../../graphql/subscriptions/ticketUpdates.api'
 
 const ticketDate = new Date(2022, 0, 29, 0, 0, 0, 0)
@@ -98,7 +99,7 @@ export const defaultArticles = (): TicketArticlesQuery =>
           __typename: 'TicketArticleEdge',
           node: {
             __typename: 'TicketArticle',
-            id: '1fs4323qr32d',
+            id: convertToGraphQLId('TicketArticle', 1),
             internalId: 1,
             createdAt: ticketDate.toISOString(),
             to: address,
@@ -149,7 +150,7 @@ export const defaultArticles = (): TicketArticlesQuery =>
           __typename: 'TicketArticleEdge',
           node: {
             __typename: 'TicketArticle',
-            id: '1fs432fdsfsg3',
+            id: convertToGraphQLId('TicketArticle', 2),
             internalId: 2,
             to: address,
             replyTo: address,
@@ -183,7 +184,7 @@ export const defaultArticles = (): TicketArticlesQuery =>
           __typename: 'TicketArticleEdge',
           node: {
             __typename: 'TicketArticle',
-            id: '1fs4sfsg3qr30f',
+            id: convertToGraphQLId('TicketArticle', 3),
             internalId: 3,
             to: address,
             replyTo: address,
@@ -256,6 +257,9 @@ export const mockTicketDetailViewGql = (options: MockOptions = {}) => {
   } else {
     mockTicketSubscription = {} as ExtendedIMockSubscription
   }
+  const mockTicketArticleSubscription = mockGraphQLSubscription(
+    TicketArticleUpdatesDocument,
+  )
 
   const waitUntilTicketLoaded = async () => {
     await waitUntil(
@@ -268,6 +272,7 @@ export const mockTicketDetailViewGql = (options: MockOptions = {}) => {
     mockApiArticles,
     mockApiTicket,
     mockTicketSubscription,
+    mockTicketArticleSubscription,
     waitUntilTicketLoaded,
   }
 }

+ 8 - 0
app/frontend/apps/mobile/pages/ticket/__tests__/ticket-detail-view.spec.ts

@@ -21,6 +21,7 @@ import { nullableMock, waitUntil } from '@tests/support/utils'
 import { flushPromises } from '@vue/test-utils'
 import { TicketDocument } from '../graphql/queries/ticket.api'
 import { TicketArticlesDocument } from '../graphql/queries/ticket/articles.api'
+import { TicketArticleUpdatesDocument } from '../graphql/subscriptions/ticketArticlesUpdates.api'
 import { TicketUpdatesDocument } from '../graphql/subscriptions/ticketUpdates.api'
 import {
   defaultArticles,
@@ -146,6 +147,9 @@ test("redirects to error page, if can't find ticket", async () => {
   mockGraphQLSubscription(TicketUpdatesDocument).error(
     new ApolloError({ errorMessage: "Couldn't find Ticket with 'id'=9866" }),
   )
+  mockGraphQLSubscription(TicketArticleUpdatesDocument).error(
+    new ApolloError({ errorMessage: "Couldn't find Ticket with 'id'=9866" }),
+  )
 
   await visitView('/tickets/9866')
 
@@ -200,6 +204,7 @@ test('change content on subscription', async () => {
       ticketUpdates: {
         __typename: 'TicketUpdatesPayload',
         ticket: nullableMock({ ...ticket, title: 'Some New Title' }),
+        ticketArticle: null,
       },
     },
   })
@@ -229,6 +234,7 @@ test('can load more articles', async () => {
               __typename: 'PageInfo',
               hasPreviousPage: false,
               startCursor: '',
+              endCursor: '',
             },
           },
         },
@@ -245,6 +251,7 @@ test('can load more articles', async () => {
             __typename: 'PageInfo',
             hasPreviousPage: true,
             startCursor: article1.cursor,
+            endCursor: '',
           },
         },
       },
@@ -253,6 +260,7 @@ test('can load more articles', async () => {
 
   mockGraphQLApi(TicketDocument).willResolve(defaultTicket())
   mockGraphQLSubscription(TicketUpdatesDocument)
+  mockGraphQLSubscription(TicketArticleUpdatesDocument)
   createMockClient([
     {
       operationDocument: TicketArticlesDocument,

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

@@ -98,9 +98,11 @@ const colorsClasses = computed(() => {
 const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
   useArticleToggleMore()
 
+const articleInternalId = computed(() => getIdFromGraphQLId(props.articleId))
+
 const { attachments: articleAttachments } = useArticleAttachments({
   ticketInternalId: props.ticketInternalId,
-  articleInternalId: getIdFromGraphQLId(props.articleId),
+  articleInternalId: articleInternalId.value,
   attachments: computed(() => props.attachments),
 })
 
@@ -114,14 +116,13 @@ const previewImage = (event: Event, attachment: TicketArticleAttachment) => {
 
 <template>
   <div
-    :id="`article-${articleId}`"
+    :id="`article-${articleInternalId}`"
     role="comment"
     class="Article relative flex"
     :class="{
       Internal: internal,
-      Right: !internal && position === 'right',
-      Left: !internal && position === 'left',
-      'flex-row-reverse': position === 'right',
+      'Right flex-row-reverse': position === 'right',
+      Left: position === 'left',
     }"
   >
     <div
@@ -253,14 +254,14 @@ const previewImage = (event: Event, attachment: TicketArticleAttachment) => {
   pointer-events: none;
 }
 
-.Right .bubbleGradient::before {
+.Right:not(.Internal) .bubbleGradient::before {
   background: linear-gradient(
     rgba(255, 255, 255, 0),
     theme('colors.blue.DEFAULT')
   );
 }
 
-.Left .bubbleGradient::before {
+.Left:not(.Internal) .bubbleGradient::before {
   background: linear-gradient(rgba(255, 255, 255, 0), theme('colors.white'));
 }
 

+ 6 - 17
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleReplyDialog.vue

@@ -14,7 +14,7 @@ import { useConfirmationDialog } from '@mobile/components/CommonConfirmation'
 interface Props {
   name: string
   ticket: TicketById
-  articleFormGroupNode: FormKitNode
+  articleFormGroupNode?: FormKitNode
   newTicketArticlePresent: boolean
   form: ShallowRef<FormRef | undefined>
 }
@@ -35,7 +35,7 @@ const label = computed(() =>
 const { waitForConfirmation } = useConfirmationDialog()
 
 const articleFormGroupNodeContext = computed(
-  () => props.articleFormGroupNode.context,
+  () => props.articleFormGroupNode?.context,
 )
 
 const rememberArticleFormData = cloneDeep(
@@ -46,12 +46,6 @@ const dialogFormIsDirty = computed(() => {
   if (!props.newTicketArticlePresent)
     return !!articleFormGroupNodeContext.value?.state.dirty
 
-  console.log(
-    'rememberArticleFormData',
-    rememberArticleFormData,
-    articleFormGroupNodeContext.value?._value,
-  )
-
   return !isEqual(
     rememberArticleFormData,
     articleFormGroupNodeContext.value?._value,
@@ -71,7 +65,7 @@ const cancelDialog = async () => {
   // For the first time we need to do nothing, because the article
   // group will be removed again from the form.
   if (props.newTicketArticlePresent) {
-    props.articleFormGroupNode.input(rememberArticleFormData)
+    props.articleFormGroupNode?.input(rememberArticleFormData)
   }
 
   closeDialog(props.name)
@@ -79,19 +73,19 @@ const cancelDialog = async () => {
 
 const discardDialog = async () => {
   const confirmed = await waitForConfirmation(
-    __('Are you sure? You current article preperation will be removed.'),
+    __('Are you sure? The prepared article will be removed.'),
   )
 
   if (!confirmed) return
 
   // Reset only the article group.
-  props.articleFormGroupNode.reset()
+  props.articleFormGroupNode?.reset()
 
   emit('discard')
   closeDialog(props.name)
 }
 
-onMounted(async () => {
+onMounted(() => {
   emit('showArticleForm')
 })
 
@@ -103,11 +97,6 @@ const close = () => {
   emit('done')
   closeDialog(props.name)
 }
-
-console.log(
-  'props.articleFormGroupNode',
-  articleFormGroupNodeContext.value?.state,
-)
 </script>
 
 <template>

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

@@ -66,11 +66,7 @@ const filterAttachments = (article: TicketArticle) => {
         :user="row.article.createdBy"
         :internal="row.article.internal"
         :content-type="row.article.contentType"
-        :position="
-          row.article.sender?.name !== 'Customer' || row.article.internal
-            ? 'left'
-            : 'right'
-        "
+        :position="row.article.sender?.name !== 'Customer' ? 'left' : 'right'"
         :security="row.article.securityState"
         :ticket-internal-id="ticket.internalId"
         :article-id="row.article.id"

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

@@ -38,7 +38,7 @@ export const useTicketArticleContext = () => {
     disposeCallbacks.forEach((callback) => callback())
     disposeCallbacks.length = 0
 
-    const actions = createArticleActions(ticket, article, {
+    const actions = createArticleActions(ticket, article, 'mobile', {
       recalculate,
       onDispose,
     }).map((action) => {

+ 88 - 0
app/frontend/apps/mobile/pages/ticket/composable/useTicketArticleReply.ts

@@ -0,0 +1,88 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import type { FormRef } from '@shared/components/Form'
+import { useDialog } from '@shared/composables/useDialog'
+import type { TicketById } from '@shared/entities/ticket/types'
+import type { Ref, ShallowRef } from 'vue'
+import { computed, ref } from 'vue'
+import { useRoute } from 'vue-router'
+
+interface ReplyDialogOptions {
+  updateFormLocation: (location: string) => void
+}
+
+export const useTicketArticleReply = (
+  ticket: Ref<TicketById | undefined>,
+  form: ShallowRef<FormRef | undefined>,
+) => {
+  const newTicketArticleRequested = ref(false)
+  const newTicketArticlePresent = ref(false)
+
+  const articleFormGroupNode = computed(() => {
+    if (!newTicketArticlePresent.value && !newTicketArticleRequested.value)
+      return undefined
+
+    return form.value?.formNode?.at('article')
+  })
+
+  const isArticleFormGroupValid = computed(() => {
+    return !!articleFormGroupNode.value?.context?.state.valid
+  })
+
+  const articleReplyDialog = useDialog({
+    name: 'ticket-article-reply',
+    component: () =>
+      import(
+        '@mobile/pages/ticket/components/TicketDetailView/ArticleReplyDialog.vue'
+      ),
+    beforeOpen: () => {
+      newTicketArticleRequested.value = true
+    },
+    afterClose: () => {
+      newTicketArticleRequested.value = false
+    },
+  })
+
+  const route = useRoute()
+
+  const openArticleReplyDialog = ({
+    updateFormLocation,
+  }: ReplyDialogOptions) => {
+    if (!ticket.value) return
+
+    articleReplyDialog.open({
+      name: articleReplyDialog.name,
+      ticket,
+      form,
+      newTicketArticlePresent,
+      articleFormGroupNode,
+      updateFormLocation,
+      onDone() {
+        newTicketArticlePresent.value = true
+      },
+      onDiscard() {
+        newTicketArticlePresent.value = false
+      },
+      onShowArticleForm() {
+        updateFormLocation('[data-ticket-article-reply-form]')
+      },
+      onHideArticleForm() {
+        if (route.name === 'TicketInformationDetails') {
+          updateFormLocation('[data-ticket-edit-form]')
+          return
+        }
+
+        updateFormLocation('body')
+      },
+    })
+  }
+
+  return {
+    articleReplyDialog,
+    newTicketArticleRequested,
+    newTicketArticlePresent,
+    articleFormGroupNode,
+    isArticleFormGroupValid,
+    openArticleReplyDialog,
+  }
+}

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

@@ -1,7 +1,8 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
 import type { ComputedRef, ShallowRef } from 'vue'
-import { computed, reactive, ref, watch } 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'
@@ -9,8 +10,19 @@ 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 { useTicketUpdateMutation } from '../graphql/mutations/update.api'
 
+interface ArticleFormValues {
+  articleType: string
+  body: string
+  internal: boolean
+  cc?: string[]
+  to?: string[]
+  subject?: string
+  attachments?: FileUploaded[]
+}
+
 export const useTicketEdit = (
   ticket: ComputedRef<TicketById | undefined>,
   form: ShallowRef<FormRef | undefined>,
@@ -41,23 +53,32 @@ export const useTicketEdit = (
     return !!ticketGroup?.context?.state.valid
   })
 
-  const newTicketArticleRequested = ref(false)
-  const newTicketArticlePresent = ref(false)
-
-  const articleFormGroupNode = computed(() => {
-    if (!newTicketArticlePresent.value && !newTicketArticleRequested.value)
-      return undefined
-
-    return form.value?.formNode?.at('article')
-  })
-
-  const isArticleFormGroupValid = computed(() => {
-    return !!articleFormGroupNode.value?.context?.state.valid
-  })
-
-  const { attributesLookup: objectAttributesLookup } = useObjectAttributes(
-    EnumObjectManagerObjects.Ticket,
-  )
+  const { attributesLookup: ticketObjectAttributesLookup } =
+    useObjectAttributes(EnumObjectManagerObjects.Ticket)
+
+  const processArticle = (
+    formId: string,
+    article: ArticleFormValues | undefined,
+  ) => {
+    if (!article) return null
+
+    const attachments = article.attachments || []
+    const files = attachments.map((file) =>
+      pick(file, ['content', 'name', 'type']),
+    )
+
+    return {
+      type: article.articleType,
+      body: article.body,
+      internal: article.internal,
+      cc: article.cc,
+      to: article.to,
+      subject: article.subject,
+      contentType: 'text/html', // TODO can be plain text
+      attachments: attachments.length ? { files, formId } : null,
+      // TODO security
+    }
+  }
 
   const editTicket = async (formData: FormData) => {
     if (!ticket.value) return undefined
@@ -67,15 +88,17 @@ export const useTicketEdit = (
     }
 
     const { internalObjectAttributeValues, additionalObjectAttributeValues } =
-      useObjectAttributeFormData(objectAttributesLookup.value, formData)
+      useObjectAttributeFormData(ticketObjectAttributesLookup.value, formData)
 
-    // TODO: Add article handling, when needed
+    const formArticle = formData.article as ArticleFormValues | undefined
+    const article = processArticle(formData.formId, formArticle)
 
     return mutationUpdate.send({
       ticketId: ticket.value.id,
       input: {
         ...internalObjectAttributeValues,
         objectAttributeValues: additionalObjectAttributeValues,
+        article,
       } as TicketUpdateInput,
     })
   }
@@ -83,10 +106,6 @@ export const useTicketEdit = (
   return {
     initialTicketValue,
     isTicketFormGroupValid,
-    isArticleFormGroupValid,
-    articleFormGroupNode,
-    newTicketArticleRequested,
-    newTicketArticlePresent,
     editTicket,
   }
 }

+ 239 - 0
app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts

@@ -0,0 +1,239 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import type { FormHandlerFunction } from '@shared/components/Form'
+import { FormHandlerExecution } from '@shared/components/Form'
+import { createArticleTypes } from '@shared/entities/ticket-article/action/plugins'
+import type { AppSpecificTicketArticleType } from '@shared/entities/ticket-article/action/plugins/types'
+import type { TicketById } from '@shared/entities/ticket/types'
+import { useTicketView } from '@shared/entities/ticket/composables/useTicketView'
+import { EnumObjectManagerObjects } from '@shared/graphql/types'
+import type { Ref } from 'vue'
+import { computed, shallowRef } from 'vue'
+import type {
+  ChangedField,
+  ReactiveFormSchemData,
+} from '@shared/components/Form/types'
+
+export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
+  const ticketArticleTypes = computed(() => {
+    return ticket.value ? createArticleTypes(ticket.value, 'mobile') : []
+  })
+
+  const currentArticleType = shallowRef<AppSpecificTicketArticleType>()
+
+  const editorMeta = computed(() => {
+    return {
+      mentionUser: {
+        groupNodeId: 'group_id',
+      },
+      ...currentArticleType?.value?.editorMeta,
+    }
+  })
+
+  const { isTicketCustomer } = useTicketView(ticket)
+
+  const ticketSchema = {
+    type: 'group',
+    name: 'ticket', // will be flattened in the form submit result
+    isGroupOrList: true,
+    children: [
+      {
+        name: 'title',
+        type: 'text',
+        label: __('Ticket title'),
+        required: true,
+      },
+      {
+        screen: 'edit',
+        object: EnumObjectManagerObjects.Ticket,
+      },
+    ],
+  }
+
+  const articleSchema = {
+    if: '$newTicketArticleRequested || $newTicketArticlePresent',
+    type: 'group',
+    name: 'article',
+    isGroupOrList: true,
+    children: [
+      {
+        name: 'articleType',
+        label: __('Article Type'),
+        labelSrOnly: true,
+        type: 'select',
+        hidden: computed(() => ticketArticleTypes.value.length === 1),
+        props: {
+          options: ticketArticleTypes,
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        name: 'internal',
+        label: __('Visibility'),
+        labelSrOnly: true,
+        hidden: isTicketCustomer,
+        type: 'select',
+        props: {
+          options: [
+            {
+              value: true,
+              label: __('Internal'),
+              icon: 'mobile-lock',
+            },
+            {
+              value: false,
+              label: __('Public'),
+              icon: 'mobile-unlock',
+            },
+          ],
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        if: '$fns.includes($currentArticleType.attributes, "to")',
+        name: 'to',
+        label: __('To'),
+        type: 'recipient',
+        props: {
+          multiple: true,
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        if: '$fns.includes($currentArticleType.attributes, "cc")',
+        name: 'cc',
+        label: __('CC'),
+        type: 'recipient',
+        props: {
+          multiple: true,
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        if: '$fns.includes($currentArticleType.attributes, "subject")',
+        name: 'subject',
+        label: __('Subject'),
+        type: 'text',
+        props: {
+          maxlength: 200,
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        // includes security and is possible to enable it
+        if: '$fns.includes($currentArticleType.attributes, "security")',
+        name: 'security',
+        label: __('Security'),
+        type: 'security',
+        props: {
+          // TODO ...
+        },
+        triggerFormUpdater: false,
+      },
+      {
+        name: 'body',
+        screen: 'edit',
+        object: EnumObjectManagerObjects.TicketArticle,
+        props: {
+          meta: editorMeta,
+        },
+        triggerFormUpdater: false,
+        required: true, // debug
+      },
+      {
+        if: '$fns.includes($currentArticleType.attributes, "attachments")',
+        type: 'file',
+        name: 'attachments',
+        props: {
+          multiple: true,
+        },
+      },
+    ],
+  }
+
+  const ticketEditSchema = [
+    {
+      isLayout: true,
+      component: 'FormGroup',
+      props: {
+        style: {
+          if: '$formLocation !== "[data-ticket-edit-form]"',
+          then: 'display: none;',
+        },
+        showDirtyMark: true,
+      },
+      children: [ticketSchema],
+    },
+    {
+      isLayout: true,
+      component: 'FormGroup',
+      props: {
+        style: {
+          if: '$formLocation !== "[data-ticket-article-reply-form]"',
+          then: 'display: none;',
+        },
+      },
+      children: [articleSchema],
+    },
+  ]
+
+  const articleTypeHandler = () => {
+    const executeHandler = (
+      execution: FormHandlerExecution,
+      schemaData: ReactiveFormSchemData,
+      changedField?: ChangedField,
+    ) => {
+      if (!schemaData.fields.articleType) return false
+      if (
+        execution === FormHandlerExecution.FieldChange &&
+        (!changedField || changedField.name !== 'articleType')
+      ) {
+        return false
+      }
+
+      return true
+    }
+
+    const handleArticleType: FormHandlerFunction = (
+      execution,
+      formNode,
+      values,
+      changeFields,
+      updateSchemaDataField,
+      schemaData,
+      changedField,
+    ) => {
+      if (!executeHandler(execution, schemaData, changedField) || !ticket.value)
+        return
+
+      if (changedField?.newValue !== changedField?.oldValue) {
+        currentArticleType.value?.onDeselected?.(ticket.value)
+      }
+
+      const newType = ticketArticleTypes.value.find(
+        (type) => type.value === changedField?.newValue,
+      )
+
+      if (!newType) return
+
+      newType.onSelected?.(ticket.value)
+      currentArticleType.value = newType
+
+      formNode?.find('internal')?.input(newType.internal, false)
+    }
+
+    return {
+      execution: [
+        FormHandlerExecution.Initial,
+        FormHandlerExecution.FieldChange,
+      ],
+      callback: handleArticleType,
+    }
+  }
+
+  return {
+    ticketEditSchema,
+    currentArticleType,
+    articleTypeHandler,
+  }
+}

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