useTicketEditForm.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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,
  66. ),
  67. }
  68. return acc
  69. }, {} as TicketArticleTypeFields)
  70. const { isTicketAgent, isTicketCustomer, isTicketEditable } =
  71. useTicketView(ticket)
  72. const isMobileApp = appName === 'mobile'
  73. const ticketSchema = {
  74. type: 'group',
  75. name: 'ticket', // will be flattened in the form submit result
  76. isGroupOrList: true,
  77. children: [
  78. ...(isMobileApp
  79. ? [
  80. {
  81. name: 'title',
  82. type: 'text',
  83. label: __('Ticket title'),
  84. required: true,
  85. },
  86. ]
  87. : []),
  88. {
  89. type: 'hidden',
  90. name: 'isDefaultFollowUpStateSet',
  91. },
  92. {
  93. screen: 'edit',
  94. object: EnumObjectManagerObjects.Ticket,
  95. },
  96. ],
  97. }
  98. const articleSchema = {
  99. // Desktop is handling the condition on top for the teleport.
  100. if: isMobileApp
  101. ? '$newTicketArticleRequested || $newTicketArticlePresent'
  102. : undefined,
  103. type: 'group',
  104. name: 'article',
  105. isGroupOrList: true,
  106. children: [
  107. {
  108. type: 'hidden',
  109. name: 'inReplyTo',
  110. },
  111. {
  112. if: '$currentArticleType.fields.subtype',
  113. type: 'hidden',
  114. name: 'subtype',
  115. },
  116. {
  117. name: 'articleType',
  118. label: __('Channel'),
  119. labelSrOnly: isMobileApp,
  120. type: 'select',
  121. hidden: computed(() => ticketArticleTypes.value.length === 1),
  122. props: {
  123. // We need to disable the auto preselection when the field
  124. // is initialized, so that we have a correct dirty state.
  125. noInitialAutoPreselect: true,
  126. options: ticketArticleTypes,
  127. },
  128. },
  129. {
  130. name: 'internal',
  131. label: __('Visibility'),
  132. labelSrOnly: isMobileApp,
  133. hidden: isTicketCustomer,
  134. type: 'select',
  135. props: {
  136. options: [
  137. {
  138. value: true,
  139. label: __('Internal'),
  140. icon: 'lock',
  141. },
  142. {
  143. value: false,
  144. label: __('Public'),
  145. icon: 'unlock',
  146. },
  147. ],
  148. },
  149. },
  150. {
  151. if: '$currentArticleType.fields.to',
  152. name: 'to',
  153. label: __('To'),
  154. type: 'recipient',
  155. validation: articleTypeFieldProps.to.validation,
  156. props: {
  157. contact: recipientContact,
  158. multiple: true,
  159. },
  160. required: articleTypeFieldProps.to.required,
  161. },
  162. {
  163. if: '$currentArticleType.fields.cc',
  164. name: 'cc',
  165. label: __('CC'),
  166. type: 'recipient',
  167. validation: articleTypeFieldProps.cc.validation,
  168. props: {
  169. contact: recipientContact,
  170. multiple: true,
  171. },
  172. },
  173. {
  174. if: '$currentArticleType.fields.subject',
  175. name: 'subject',
  176. label: __('Subject'),
  177. type: 'text',
  178. validation: articleTypeFieldProps.subject.validation,
  179. props: {
  180. maxlength: 200,
  181. },
  182. required: articleTypeFieldProps.subject.required,
  183. },
  184. {
  185. if: '$securityIntegration === true && $currentArticleType.fields.security',
  186. name: 'security',
  187. label: __('Security'),
  188. type: 'security',
  189. validation: articleTypeFieldProps.security.validation,
  190. },
  191. {
  192. name: 'body',
  193. screen: 'edit',
  194. object: EnumObjectManagerObjects.TicketArticle,
  195. validation: articleTypeFieldProps.body.validation,
  196. props: {
  197. ticketId: computed(() => ticket.value?.internalId),
  198. customerId: computed(() => ticket.value?.customer.internalId),
  199. contentType: editorType,
  200. meta: editorMeta,
  201. },
  202. required: articleTypeFieldProps.body.required,
  203. },
  204. {
  205. if: '$currentArticleType.fields.attachments',
  206. type: 'file',
  207. name: 'attachments',
  208. label: __('Attachment'),
  209. labelSrOnly: true,
  210. validation: articleTypeFieldProps.attachments.validation,
  211. props: {
  212. multiple: computed(() =>
  213. Boolean(
  214. typeof currentArticleType.value?.fields?.attachments?.multiple ===
  215. 'boolean'
  216. ? currentArticleType.value?.fields?.attachments?.multiple
  217. : true,
  218. ),
  219. ),
  220. allowedFiles: computed(
  221. () =>
  222. currentArticleType.value?.fields?.attachments?.allowedFiles ||
  223. null,
  224. ),
  225. accept: computed(
  226. () => currentArticleType.value?.fields?.attachments?.accept || null,
  227. ),
  228. },
  229. required: articleTypeFieldProps.attachments.required,
  230. },
  231. ],
  232. }
  233. const articleTypeChangeHandler = () => {
  234. const executeTypeChangeHandler = (
  235. execution: FormHandlerExecution,
  236. schemaData: ReactiveFormSchemData,
  237. changedField?: ChangedField,
  238. ) => {
  239. if (!schemaData.fields.articleType) return false
  240. return !(
  241. execution === FormHandlerExecution.FieldChange &&
  242. (!changedField || changedField.name !== 'articleType')
  243. )
  244. }
  245. const handleArticleType: FormHandlerFunction = (
  246. execution,
  247. reactivity,
  248. data,
  249. ) => {
  250. const { formNode, changedField, formUpdaterData } = data
  251. const { schemaData } = reactivity
  252. if (
  253. execution === FormHandlerExecution.Initial &&
  254. formUpdaterData?.fields.articleType?.value
  255. ) {
  256. currentArticleType.value =
  257. ticketArticleTypeValueLookup.value[
  258. formUpdaterData.fields.articleType.value
  259. ]
  260. }
  261. if (
  262. !executeTypeChangeHandler(execution, schemaData, changedField) ||
  263. !ticket.value ||
  264. !formNode
  265. )
  266. return
  267. const body = formNode.find('body', 'name')
  268. const context = {
  269. body: body?.context as unknown as FieldEditorContext,
  270. }
  271. if (changedField?.newValue !== changedField?.oldValue) {
  272. currentArticleType.value?.onDeselected?.(ticket.value, context)
  273. }
  274. if (!changedField?.newValue) return
  275. const newType =
  276. ticketArticleTypeValueLookup.value[changedField?.newValue as string]
  277. if (!newType) return
  278. if (!formNode.context?._open) {
  279. newType.onSelected?.(ticket.value, context, form.value)
  280. }
  281. currentArticleType.value = newType
  282. formNode.find('internal')?.input(newType.internal, false)
  283. }
  284. return {
  285. execution: [
  286. FormHandlerExecution.Initial,
  287. FormHandlerExecution.FieldChange,
  288. ],
  289. callback: handleArticleType,
  290. }
  291. }
  292. const articleTypeSelectHandler = (formNode: FormKitNode) => {
  293. // this is called only when user replied to an article, but the type inside form did not change
  294. // (because dialog was opened before, and type was changed then, but we still need to trigger select, because visually it's what happens)
  295. formNode.on('article-reply-open', ({ payload }) => {
  296. if (!payload || !ticket.value) return
  297. const articleType = ticketArticleTypeValueLookup.value[payload as string]
  298. if (!articleType) return
  299. const body = formNode.find('body', 'name') as FormKitNode
  300. const context = {
  301. body: body.context as unknown as FieldEditorContext,
  302. }
  303. articleType.onOpened?.(ticket.value, context, form.value)
  304. })
  305. }
  306. const application = useApplicationStore()
  307. const securityIntegration = computed<boolean>(
  308. () =>
  309. (application.config.smime_integration ||
  310. application.config.pgp_integration) ??
  311. false,
  312. )
  313. return {
  314. ticketSchema,
  315. articleSchema,
  316. currentArticleType,
  317. ticketArticleTypes,
  318. securityIntegration,
  319. isTicketAgent,
  320. isTicketCustomer,
  321. isTicketEditable,
  322. articleTypeHandler: articleTypeChangeHandler,
  323. articleTypeSelectHandler,
  324. }
  325. }