Form.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. computed,
  5. ref,
  6. reactive,
  7. toRef,
  8. watch,
  9. markRaw,
  10. ConcreteComponent,
  11. nextTick,
  12. Ref,
  13. } from 'vue'
  14. import { FormKit, FormKitSchema } from '@formkit/vue'
  15. import type {
  16. FormKitPlugin,
  17. FormKitSchemaNode,
  18. FormKitSchemaCondition,
  19. FormKitNode,
  20. FormKitClasses,
  21. FormKitSchemaDOMNode,
  22. FormKitSchemaComponent,
  23. } from '@formkit/core'
  24. import { useTimeoutFn } from '@vueuse/shared'
  25. import UserError from '@shared/errors/UserError'
  26. import { FormSchemaId } from '@shared/graphql/types'
  27. import { QueryHandler } from '@shared/server/apollo/handler'
  28. import { useFormSchemaQuery } from './graphql/queries/formSchema.api'
  29. import {
  30. type FormData,
  31. type FormSchemaField,
  32. type FormSchemaLayout,
  33. type FormSchemaNode,
  34. type FormValues,
  35. type ReactiveFormSchemData,
  36. FormValidationVisibility,
  37. FormSchemaGroupOrList,
  38. } from './types'
  39. import FormLayout from './FormLayout.vue'
  40. import FormGroup from './FormGroup.vue'
  41. // TODO:
  42. // - Maybe some default buttons inside the components with loading cycle on submit?
  43. // (- Disabled form on submit? (i think it's the default of FormKit, but only when a promise will be returned from the submit handler))
  44. // - Reset/Clear form handling?
  45. // - Add usage of "clearErrors(true)"?
  46. export interface Props {
  47. schema?: FormSchemaNode[]
  48. formSchemaId?: FormSchemaId
  49. changeFields?: Record<string, FormSchemaField>
  50. formKitPlugins?: FormKitPlugin[]
  51. formKitSectionsSchema?: Record<
  52. string,
  53. Partial<FormKitSchemaNode> | FormKitSchemaCondition
  54. >
  55. class?: FormKitClasses | string | Record<string, boolean>
  56. // Can be used to define initial values on frontend side and fetched schema from the server.
  57. initialValues?: Partial<FormValues>
  58. queryParams?: Record<string, unknown>
  59. validationVisibility?: FormValidationVisibility
  60. disabled?: boolean
  61. // Implement the submit in this way, because we need to react on async usage of the submit function.
  62. onSubmit?: (values: FormData) => Promise<void> | void
  63. }
  64. // Zammad currently expects formIds to be BigInts. Maybe convert to UUIDs later.
  65. // const formId = `form-${getUuid()}`
  66. // This is the formId generation logic from the legacy desktop app.
  67. let formId = new Date().getTime() + Math.floor(Math.random() * 99999).toString()
  68. formId = formId.substr(formId.length - 9, 9)
  69. const props = withDefaults(defineProps<Props>(), {
  70. schema: () => {
  71. return []
  72. },
  73. changeFields: () => {
  74. return {}
  75. },
  76. validationVisibility: FormValidationVisibility.Submit,
  77. disabled: false,
  78. })
  79. // Rename prop 'class' for usage in the template, because of reserved word
  80. const localClass = toRef(props, 'class')
  81. const emit = defineEmits<{
  82. (e: 'changed', newValue: unknown, fieldName: string): void
  83. (e: 'node', node: FormKitNode): void
  84. }>()
  85. const formNode: Ref<FormKitNode | undefined> = ref()
  86. const setFormNode = (node: FormKitNode) => {
  87. formNode.value = node
  88. emit('node', node)
  89. }
  90. const formNodeContext = computed(() => formNode.value?.context)
  91. defineExpose({
  92. formNode,
  93. })
  94. // Use the node context value, instead of the v-model, because of performance reason.
  95. const values = computed<FormValues>(() => {
  96. if (!formNodeContext.value) {
  97. return {}
  98. }
  99. return formNodeContext.value.value
  100. })
  101. const updateSchemaProcessing = ref(false)
  102. const onSubmit = (values: FormData): Promise<void> | void => {
  103. // Needs to be checked, because the 'onSubmit' function is not required.
  104. if (!props.onSubmit) return undefined
  105. const emitValues = {
  106. ...values,
  107. formId,
  108. }
  109. const submitResult = props.onSubmit(emitValues)
  110. // TODO: Maybe we need to handle the disabled state on submit on our own. In clarification with FormKit (https://github.com/formkit/formkit/issues/236).
  111. if (submitResult instanceof Promise) {
  112. return submitResult.catch((errors: UserError) => {
  113. formNode.value?.setErrors(
  114. errors.generalErrors as string[],
  115. errors.getFieldErrorList(),
  116. )
  117. })
  118. }
  119. return submitResult
  120. }
  121. const coreWorkflowActive = ref(false)
  122. const coreWorkflowChanges = ref<Record<string, FormSchemaField>>({})
  123. const changedValuePlugin = (node: FormKitNode) => {
  124. node.on('input', ({ payload: value, origin: node }) => {
  125. // TODO: trigger update form check (e.g. core workflow)
  126. // Or maybe also some "update"-flag on field level?
  127. if (coreWorkflowActive.value) {
  128. updateSchemaProcessing.value = true
  129. setTimeout(() => {
  130. // TODO: ... do some needed stuff
  131. coreWorkflowChanges.value = {}
  132. updateSchemaProcessing.value = false
  133. }, 2000)
  134. }
  135. emit('changed', value, node.name)
  136. })
  137. }
  138. const localFormKitPlugins = computed(() => {
  139. return [changedValuePlugin, ...(props.formKitPlugins || [])]
  140. })
  141. const formConfig = computed(() => {
  142. return {
  143. validationVisibility: props.validationVisibility,
  144. }
  145. })
  146. // Define the additional component library for the used components which are not form fields.
  147. // Because of a typescript error, we need to cased the type: https://github.com/formkit/formkit/issues/274
  148. const additionalComponentLibrary = {
  149. FormLayout: markRaw(FormLayout) as unknown as ConcreteComponent,
  150. FormGroup: markRaw(FormGroup) as unknown as ConcreteComponent,
  151. }
  152. // Define the static schema, which will be filled with the real fields from the `schemaData`.
  153. const staticSchema: FormKitSchemaNode[] = []
  154. const schemaData = reactive<ReactiveFormSchemData>({
  155. fields: {},
  156. })
  157. const updateSchemaDataField = (field: FormSchemaField) => {
  158. const { show, props: specificProps, ...fieldProps } = field
  159. const showField = show ?? true
  160. if (schemaData.fields[field.name]) {
  161. schemaData.fields[field.name] = {
  162. show: showField,
  163. props: Object.assign(
  164. schemaData.fields[field.name].props,
  165. fieldProps,
  166. specificProps,
  167. ),
  168. }
  169. } else {
  170. schemaData.fields[field.name] = {
  171. show: showField,
  172. props: Object.assign(fieldProps, specificProps),
  173. }
  174. }
  175. }
  176. const buildStaticSchema = (schema: FormSchemaNode[]) => {
  177. const buildFormKitField = (
  178. field: FormSchemaField,
  179. ): FormKitSchemaComponent => {
  180. return {
  181. $cmp: 'FormKit',
  182. if: `$fields.${field.name}.show`,
  183. bind: `$fields.${field.name}.props`,
  184. props: {
  185. type: field.type,
  186. key: field.name,
  187. id: field.id,
  188. formId,
  189. value: props.initialValues?.[field.name] ?? field.value,
  190. },
  191. }
  192. }
  193. const getLayoutType = (
  194. layoutItem: FormSchemaLayout,
  195. ): FormKitSchemaDOMNode | FormKitSchemaComponent => {
  196. if ('component' in layoutItem) {
  197. return {
  198. $cmp: layoutItem.component,
  199. props: layoutItem.props,
  200. }
  201. }
  202. return {
  203. $el: layoutItem.element,
  204. attrs: layoutItem.attrs,
  205. }
  206. }
  207. schema.forEach((node) => {
  208. if ((node as FormSchemaLayout).isLayout) {
  209. const layoutItem = node as FormSchemaLayout
  210. if (typeof layoutItem.children === 'string') {
  211. staticSchema.push({
  212. ...getLayoutType(layoutItem),
  213. children: layoutItem.children,
  214. })
  215. } else {
  216. const childrens = layoutItem.children.map((childNode) => {
  217. if (typeof childNode === 'string') {
  218. return childNode
  219. }
  220. if ((childNode as FormSchemaLayout).isLayout) {
  221. const layoutItemChildNode = childNode as FormSchemaLayout
  222. return {
  223. ...getLayoutType(layoutItemChildNode),
  224. children: layoutItemChildNode.children as
  225. | string
  226. | FormKitSchemaNode[]
  227. | FormKitSchemaCondition,
  228. }
  229. }
  230. updateSchemaDataField(childNode as FormSchemaField)
  231. return buildFormKitField(childNode as FormSchemaField)
  232. })
  233. staticSchema.push({
  234. ...getLayoutType(layoutItem),
  235. children: childrens,
  236. })
  237. }
  238. }
  239. // At the moment we support only one level of group/list fields, no recursive implementation.
  240. else if (
  241. (node as FormSchemaGroupOrList).type === 'group' ||
  242. (node as FormSchemaGroupOrList).type === 'list'
  243. ) {
  244. const groupOrListField = node as FormSchemaGroupOrList
  245. const childrenStaticSchema: FormKitSchemaComponent[] = []
  246. groupOrListField.children.forEach((childField) => {
  247. childrenStaticSchema.push(buildFormKitField(childField))
  248. updateSchemaDataField(childField)
  249. })
  250. staticSchema.push({
  251. $cmp: 'FormKit',
  252. props: {
  253. type: groupOrListField.type,
  254. name: groupOrListField.name,
  255. key: groupOrListField.name,
  256. },
  257. children: childrenStaticSchema,
  258. })
  259. } else {
  260. const field = node as FormSchemaField
  261. staticSchema.push(buildFormKitField(field))
  262. updateSchemaDataField(field)
  263. }
  264. })
  265. }
  266. const localChangeFields = computed(() => {
  267. // if (props.formSchemaId) return coreWorkflowChanges.value
  268. return props.changeFields
  269. })
  270. watch(
  271. localChangeFields,
  272. (newChangeFields) => {
  273. Object.keys(newChangeFields).forEach((fieldName) => {
  274. const field = {
  275. ...newChangeFields[fieldName],
  276. name: fieldName,
  277. }
  278. updateSchemaDataField(field)
  279. nextTick(() => {
  280. if (field.value !== values.value[fieldName]) {
  281. formNode.value?.at(fieldName)?.input(field.value)
  282. }
  283. })
  284. })
  285. },
  286. { deep: true },
  287. )
  288. const localDisabled = computed(() => {
  289. if (props.disabled) return props.disabled
  290. return updateSchemaProcessing.value
  291. })
  292. const showInitialLoadingAnimation = ref(false)
  293. const {
  294. start: startLoadingAnimationTimeout,
  295. stop: stopLoadingAnimationTimeout,
  296. } = useTimeoutFn(
  297. () => {
  298. showInitialLoadingAnimation.value = !showInitialLoadingAnimation.value
  299. },
  300. 300,
  301. { immediate: false },
  302. )
  303. const toggleInitialLoadingAnimation = () => {
  304. stopLoadingAnimationTimeout()
  305. startLoadingAnimationTimeout()
  306. }
  307. // TODO: maybe we should react on schema changes and rebuild the static schema with a new form-id and re-rendering of
  308. // the complete form (= use the formId as the key for the whole form to trigger the re-rendering of the component...)
  309. if (props.formSchemaId) {
  310. // TODO: call the GraphQL-Query to fetch the schema.
  311. toggleInitialLoadingAnimation()
  312. new QueryHandler(
  313. useFormSchemaQuery({ formSchemaId: props.formSchemaId }),
  314. ).watchOnResult((queryResult) => {
  315. if (queryResult?.formSchema) {
  316. buildStaticSchema(queryResult.formSchema)
  317. toggleInitialLoadingAnimation()
  318. }
  319. })
  320. } else if (props.schema) {
  321. // localSchema.value = toRef(props, 'schema').value
  322. buildStaticSchema(toRef(props, 'schema').value)
  323. }
  324. </script>
  325. <template>
  326. <FormKit
  327. v-if="Object.keys(schemaData.fields).length > 0 || $slots.fields"
  328. :id="formId"
  329. type="form"
  330. :config="formConfig"
  331. :form-class="localClass"
  332. :actions="false"
  333. :incomplete-message="false"
  334. :plugins="localFormKitPlugins"
  335. :sections-schema="formKitSectionsSchema"
  336. :disabled="localDisabled"
  337. @node="setFormNode"
  338. @submit="onSubmit"
  339. >
  340. <slot name="before-fields" />
  341. <slot
  342. name="fields"
  343. :schema="staticSchema"
  344. :data="schemaData"
  345. :library="additionalComponentLibrary"
  346. >
  347. <FormKitSchema
  348. :schema="staticSchema"
  349. :data="schemaData"
  350. :library="additionalComponentLibrary"
  351. />
  352. </slot>
  353. <slot name="after-fields" />
  354. </FormKit>
  355. <div
  356. v-else-if="showInitialLoadingAnimation"
  357. class="flex items-center justify-center"
  358. >
  359. <CommonIcon name="loader" animation="spin" />
  360. </div>
  361. </template>