zammad/zammad

View on GitHub
app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts

Summary

Maintainability
D
2 days
Test Coverage
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { computed, shallowRef } from 'vue'
import type { Ref } from 'vue'
import type { FormKitNode } from '@formkit/core'

import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import { createArticleTypes } from '#shared/entities/ticket-article/action/plugins/index.ts'
import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
import type {
  AppSpecificTicketArticleType,
  TicketArticleTypeFields,
} from '#shared/entities/ticket-article/action/plugins/types.ts'
import type { TicketById } from '#shared/entities/ticket/types.ts'
import type {
  ChangedField,
  ReactiveFormSchemData,
  FormHandlerFunction,
  FormRef,
} from '#shared/components/Form/types.ts'
import type { FieldEditorContext } from '#shared/components/Form/fields/FieldEditor/types.ts'

export const useTicketEditForm = (
  ticket: Ref<TicketById | undefined>,
  form: Ref<FormRef | undefined>,
) => {
  const ticketArticleTypes = computed(() => {
    return ticket.value ? createArticleTypes(ticket.value, 'mobile') : []
  })

  const currentArticleType = shallowRef<AppSpecificTicketArticleType>()

  const recipientContact = computed(
    () => currentArticleType.value?.options?.recipientContact,
  )
  const editorType = computed(() => currentArticleType.value?.contentType)

  const editorMeta = computed(() => {
    return {
      mentionUser: {
        groupNodeName: 'group_id',
      },
      mentionKnowledgeBase: {
        attachmentsNodeName: 'attachments',
      },
      ...currentArticleType.value?.editorMeta,
    }
  })

  const articleTypeFields = [
    'to',
    'cc',
    'subject',
    'body',
    'attachments',
    'security',
  ] as const

  const articleTypeFieldProps = articleTypeFields.reduce((acc, field) => {
    acc[field] = {
      validation: computed(
        () => currentArticleType.value?.fields?.[field]?.validation || null,
      ),
      required: computed(
        () => currentArticleType.value?.fields?.[field]?.required || false,
      ),
    }

    return acc
  }, {} as TicketArticleTypeFields)

  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,
      },
      {
        type: 'hidden',
        name: 'isDefaultFollowUpStateSet',
      },
      {
        screen: 'edit',
        object: EnumObjectManagerObjects.Ticket,
      },
    ],
  }

  const articleSchema = {
    if: '$newTicketArticleRequested || $newTicketArticlePresent',
    type: 'group',
    name: 'article',
    isGroupOrList: true,
    children: [
      {
        type: 'hidden',
        name: 'inReplyTo',
      },
      {
        if: '$currentArticleType.fields.subtype',
        type: 'hidden',
        name: 'subtype',
      },
      {
        name: 'articleType',
        label: __('Article Type'),
        labelSrOnly: true,
        type: 'select',
        hidden: computed(() => ticketArticleTypes.value.length === 1),
        props: {
          options: ticketArticleTypes,
        },
      },
      {
        name: 'internal',
        label: __('Visibility'),
        labelSrOnly: true,
        hidden: isTicketCustomer,
        type: 'select',
        props: {
          options: [
            {
              value: true,
              label: __('Internal'),
              icon: 'lock',
            },
            {
              value: false,
              label: __('Public'),
              icon: 'unlock',
            },
          ],
        },
        triggerFormUpdater: false,
      },
      {
        if: '$currentArticleType.fields.to)',
        name: 'to',
        label: __('To'),
        type: 'recipient',
        validation: articleTypeFieldProps.to.validation,
        props: {
          contact: recipientContact,
          multiple: true,
        },
        required: articleTypeFieldProps.to.required,
      },
      {
        if: '$currentArticleType.fields.cc',
        name: 'cc',
        label: __('CC'),
        type: 'recipient',
        validation: articleTypeFieldProps.cc.validation,
        props: {
          contact: recipientContact,
          multiple: true,
        },
      },
      {
        if: '$currentArticleType.fields.subject',
        name: 'subject',
        label: __('Subject'),
        type: 'text',
        validation: articleTypeFieldProps.subject.validation,
        props: {
          maxlength: 200,
        },
        triggerFormUpdater: false,
        required: articleTypeFieldProps.subject.required,
      },
      {
        if: '$securityIntegration === true && $currentArticleType.fields.security',
        name: 'security',
        label: __('Security'),
        type: 'security',
        validation: articleTypeFieldProps.security.validation,
        triggerFormUpdater: false,
      },
      {
        name: 'body',
        screen: 'edit',
        object: EnumObjectManagerObjects.TicketArticle,
        validation: articleTypeFieldProps.body.validation,
        props: {
          ticketId: computed(() => ticket.value?.internalId),
          customerId: computed(() => ticket.value?.customer.internalId),
          contentType: editorType,
          meta: editorMeta,
        },
        required: articleTypeFieldProps.body.required,
        triggerFormUpdater: true,
      },
      {
        if: '$currentArticleType.fields.attachments)',
        type: 'file',
        name: 'attachments',
        label: __('Attachment'),
        labelSrOnly: true,
        validation: articleTypeFieldProps.attachments.validation,
        props: {
          multiple: computed(() =>
            Boolean(
              typeof currentArticleType.value?.fields?.attachments?.multiple ===
                'boolean'
                ? currentArticleType.value?.fields?.attachments?.multiple
                : true,
            ),
          ),
          allowedFiles: computed(
            () =>
              currentArticleType.value?.fields?.attachments?.allowedFiles ||
              null,
          ),
          accept: computed(
            () => currentArticleType.value?.fields?.attachments?.accept || null,
          ),
        },
        required: articleTypeFieldProps.attachments.required,
      },
    ],
  }

  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 articleTypeChangeHandler = () => {
    const executeHandler = (
      execution: FormHandlerExecution,
      schemaData: ReactiveFormSchemData,
      changedField?: ChangedField,
    ) => {
      if (!schemaData.fields.articleType) return false
      return !(
        execution === FormHandlerExecution.FieldChange &&
        (!changedField || changedField.name !== 'articleType')
      )
    }

    const handleArticleType: FormHandlerFunction = (
      execution,
      reactivity,
      data,
    ) => {
      const { formNode, changedField } = data
      const { schemaData } = reactivity

      if (
        !executeHandler(execution, schemaData, changedField) ||
        !ticket.value ||
        !formNode
      )
        return
      const body = formNode.find('body', 'name')
      const context = {
        body: body?.context as unknown as FieldEditorContext,
      }

      if (changedField?.newValue !== changedField?.oldValue) {
        currentArticleType.value?.onDeselected?.(ticket.value, context)
      }

      const newType = ticketArticleTypes.value.find(
        (type) => type.value === changedField?.newValue,
      )
      if (!newType) return

      if (!formNode.context?._open) {
        newType.onSelected?.(ticket.value, context, form.value)
      }
      currentArticleType.value = newType

      formNode.find('internal')?.input(newType.internal, false)
    }

    return {
      execution: [
        FormHandlerExecution.Initial,
        FormHandlerExecution.FieldChange,
      ],
      callback: handleArticleType,
    }
  }

  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, form.value)
    })
  }

  return {
    ticketEditSchema,
    currentArticleType,
    articleTypeHandler: articleTypeChangeHandler,
    articleTypeSelectHandler,
  }
}