app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts
// 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,
}
}