useTicketEditForm.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { keyBy } from 'lodash-es'
  3. import { computed, shallowRef } from 'vue'
  4. import type { FieldEditorContext } from '#shared/components/Form/fields/FieldEditor/types.ts'
  5. import { FormHandlerExecution } from '#shared/components/Form/types.ts'
  6. import type {
  7. ChangedField,
  8. ReactiveFormSchemData,
  9. FormHandlerFunction,
  10. FormRef,
  11. } from '#shared/components/Form/types.ts'
  12. import { useAppName } from '#shared/composables/useAppName.ts'
  13. import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
  14. import type { TicketById } from '#shared/entities/ticket/types.ts'
  15. import { createArticleTypes } from '#shared/entities/ticket-article/action/plugins/index.ts'
  16. import type {
  17. AppSpecificTicketArticleType,
  18. TicketArticleTypeFields,
  19. } from '#shared/entities/ticket-article/action/plugins/types.ts'
  20. import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
  21. import { useApplicationStore } from '#shared/stores/application.ts'
  22. import type { FormKitNode } from '@formkit/core'
  23. import type { Ref } from 'vue'
  24. export const useTicketEditForm = (
  25. ticket: Ref<TicketById | undefined>,
  26. form: Ref<FormRef | undefined>,
  27. ) => {
  28. const appName = useAppName()
  29. const ticketArticleTypes = computed(() => {
  30. return ticket.value ? createArticleTypes(ticket.value, appName) : []
  31. })
  32. const ticketArticleTypeValueLookup = computed(() =>
  33. keyBy(ticketArticleTypes.value, 'value'),
  34. )
  35. const currentArticleType = shallowRef<AppSpecificTicketArticleType>()
  36. const recipientContact = computed(
  37. () => currentArticleType.value?.options?.recipientContact,
  38. )
  39. const editorType = computed(() => currentArticleType.value?.contentType)
  40. const editorMeta = computed(() => {
  41. return {
  42. mentionUser: {
  43. groupNodeName: 'group_id',
  44. },
  45. mentionKnowledgeBase: {
  46. attachmentsNodeName: 'attachments',
  47. },
  48. ...currentArticleType.value?.editorMeta,
  49. }
  50. })
  51. const articleTypeFields = [
  52. 'to',
  53. 'cc',
  54. 'subject',
  55. 'body',
  56. 'attachments',
  57. 'security',
  58. ] as const
  59. const articleTypeFieldProps = articleTypeFields.reduce((acc, field) => {
  60. acc[field] = {
  61. validation: computed(
  62. () => currentArticleType.value?.fields?.[field]?.validation || null,
  63. ),
  64. required: computed(
  65. () => currentArticleType.value?.fields?.[field]?.required || false,
  66. ),
  67. }
  68. return acc
  69. }, {} as TicketArticleTypeFields)
  70. const { isTicketCustomer } = useTicketView(ticket)
  71. const isMobileApp = appName === 'mobile'
  72. const ticketSchema = {
  73. type: 'group',
  74. name: 'ticket', // will be flattened in the form submit result
  75. isGroupOrList: true,
  76. children: [
  77. ...(isMobileApp
  78. ? [
  79. {
  80. name: 'title',
  81. type: 'text',
  82. label: __('Ticket title'),
  83. required: true,
  84. },
  85. ]
  86. : []),
  87. {
  88. type: 'hidden',
  89. name: 'isDefaultFollowUpStateSet',
  90. },
  91. {
  92. screen: 'edit',
  93. object: EnumObjectManagerObjects.Ticket,
  94. },
  95. ],
  96. }
  97. const articleSchema = {
  98. // Desktop is handling the condition on top for the teleport.
  99. if: isMobileApp
  100. ? '$newTicketArticleRequested || $newTicketArticlePresent'
  101. : undefined,
  102. type: 'group',
  103. name: 'article',
  104. isGroupOrList: true,
  105. children: [
  106. {
  107. type: 'hidden',
  108. name: 'inReplyTo',
  109. },
  110. {
  111. if: '$currentArticleType.fields.subtype',
  112. type: 'hidden',
  113. name: 'subtype',
  114. },
  115. {
  116. name: 'articleType',
  117. label: __('Channel'),
  118. labelSrOnly: isMobileApp,
  119. type: 'select',
  120. hidden: computed(() => ticketArticleTypes.value.length === 1),
  121. props: {
  122. options: ticketArticleTypes,
  123. },
  124. },
  125. {
  126. name: 'internal',
  127. label: __('Visibility'),
  128. labelSrOnly: isMobileApp,
  129. hidden: isTicketCustomer,
  130. type: 'select',
  131. props: {
  132. options: [
  133. {
  134. value: true,
  135. label: __('Internal'),
  136. icon: 'lock',
  137. },
  138. {
  139. value: false,
  140. label: __('Public'),
  141. icon: 'unlock',
  142. },
  143. ],
  144. },
  145. },
  146. {
  147. if: '$currentArticleType.fields.to',
  148. name: 'to',
  149. label: __('To'),
  150. type: 'recipient',
  151. validation: articleTypeFieldProps.to.validation,
  152. props: {
  153. contact: recipientContact,
  154. multiple: true,
  155. },
  156. required: articleTypeFieldProps.to.required,
  157. },
  158. {
  159. if: '$currentArticleType.fields.cc',
  160. name: 'cc',
  161. label: __('CC'),
  162. type: 'recipient',
  163. validation: articleTypeFieldProps.cc.validation,
  164. props: {
  165. contact: recipientContact,
  166. multiple: true,
  167. },
  168. },
  169. {
  170. if: '$currentArticleType.fields.subject',
  171. name: 'subject',
  172. label: __('Subject'),
  173. type: 'text',
  174. validation: articleTypeFieldProps.subject.validation,
  175. props: {
  176. maxlength: 200,
  177. },
  178. required: articleTypeFieldProps.subject.required,
  179. },
  180. {
  181. if: '$securityIntegration === true && $currentArticleType.fields.security',
  182. name: 'security',
  183. label: __('Security'),
  184. type: 'security',
  185. validation: articleTypeFieldProps.security.validation,
  186. },
  187. {
  188. name: 'body',
  189. screen: 'edit',
  190. object: EnumObjectManagerObjects.TicketArticle,
  191. validation: articleTypeFieldProps.body.validation,
  192. props: {
  193. ticketId: computed(() => ticket.value?.internalId),
  194. customerId: computed(() => ticket.value?.customer.internalId),
  195. contentType: editorType,
  196. meta: editorMeta,
  197. },
  198. required: articleTypeFieldProps.body.required,
  199. },
  200. {
  201. if: '$currentArticleType.fields.attachments',
  202. type: 'file',
  203. name: 'attachments',
  204. label: __('Attachment'),
  205. labelSrOnly: true,
  206. validation: articleTypeFieldProps.attachments.validation,
  207. props: {
  208. multiple: computed(() =>
  209. Boolean(
  210. typeof currentArticleType.value?.fields?.attachments?.multiple ===
  211. 'boolean'
  212. ? currentArticleType.value?.fields?.attachments?.multiple
  213. : true,
  214. ),
  215. ),
  216. allowedFiles: computed(
  217. () =>
  218. currentArticleType.value?.fields?.attachments?.allowedFiles ||
  219. null,
  220. ),
  221. accept: computed(
  222. () => currentArticleType.value?.fields?.attachments?.accept || null,
  223. ),
  224. },
  225. required: articleTypeFieldProps.attachments.required,
  226. },
  227. ],
  228. }
  229. const articleTypeChangeHandler = () => {
  230. const executeTypeChangeHandler = (
  231. execution: FormHandlerExecution,
  232. schemaData: ReactiveFormSchemData,
  233. changedField?: ChangedField,
  234. ) => {
  235. if (!schemaData.fields.articleType) return false
  236. return !(
  237. execution === FormHandlerExecution.FieldChange &&
  238. (!changedField || changedField.name !== 'articleType')
  239. )
  240. }
  241. const handleArticleType: FormHandlerFunction = (
  242. execution,
  243. reactivity,
  244. data,
  245. ) => {
  246. const { formNode, changedField, formUpdaterData } = data
  247. const { schemaData } = reactivity
  248. if (
  249. execution === FormHandlerExecution.Initial &&
  250. formUpdaterData?.fields.articleType?.value
  251. ) {
  252. currentArticleType.value =
  253. ticketArticleTypeValueLookup.value[
  254. formUpdaterData.fields.articleType.value
  255. ]
  256. }
  257. if (
  258. !executeTypeChangeHandler(execution, schemaData, changedField) ||
  259. !ticket.value ||
  260. !formNode
  261. )
  262. return
  263. const body = formNode.find('body', 'name')
  264. const context = {
  265. body: body?.context as unknown as FieldEditorContext,
  266. }
  267. if (changedField?.newValue !== changedField?.oldValue) {
  268. currentArticleType.value?.onDeselected?.(ticket.value, context)
  269. }
  270. if (!changedField?.newValue) return
  271. const newType =
  272. ticketArticleTypeValueLookup.value[changedField?.newValue as string]
  273. if (!newType) return
  274. if (!formNode.context?._open) {
  275. newType.onSelected?.(ticket.value, context, form.value)
  276. }
  277. currentArticleType.value = newType
  278. formNode.find('internal')?.input(newType.internal, false)
  279. }
  280. return {
  281. execution: [
  282. FormHandlerExecution.Initial,
  283. FormHandlerExecution.FieldChange,
  284. ],
  285. callback: handleArticleType,
  286. }
  287. }
  288. const articleTypeSelectHandler = (formNode: FormKitNode) => {
  289. // this is called only when user replied to an article, but the type inside form did not change
  290. // (because dialog was opened before, and type was changed then, but we still need to trigger select, because visually it's what happens)
  291. formNode.on('article-reply-open', ({ payload }) => {
  292. if (!payload || !ticket.value) return
  293. const articleType = ticketArticleTypeValueLookup.value[payload as string]
  294. if (!articleType) return
  295. const body = formNode.find('body', 'name') as FormKitNode
  296. const context = {
  297. body: body.context as unknown as FieldEditorContext,
  298. }
  299. articleType.onOpened?.(ticket.value, context, form.value)
  300. })
  301. }
  302. const application = useApplicationStore()
  303. const securityIntegration = computed<boolean>(
  304. () =>
  305. (application.config.smime_integration ||
  306. application.config.pgp_integration) ??
  307. false,
  308. )
  309. return {
  310. ticketSchema,
  311. articleSchema,
  312. currentArticleType,
  313. ticketArticleTypes,
  314. securityIntegration,
  315. isTicketCustomer,
  316. articleTypeHandler: articleTypeChangeHandler,
  317. articleTypeSelectHandler,
  318. }
  319. }