Form.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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. if (errors instanceof UserError) {
  114. formNode.value?.setErrors(
  115. errors.generalErrors as string[],
  116. errors.getFieldErrorList(),
  117. )
  118. }
  119. })
  120. }
  121. return submitResult
  122. }
  123. const coreWorkflowActive = ref(false)
  124. const coreWorkflowChanges = ref<Record<string, FormSchemaField>>({})
  125. const changedValuePlugin = (node: FormKitNode) => {
  126. node.on('input', ({ payload: value, origin: node }) => {
  127. // TODO: trigger update form check (e.g. core workflow)
  128. // Or maybe also some "update"-flag on field level?
  129. if (coreWorkflowActive.value) {
  130. updateSchemaProcessing.value = true
  131. setTimeout(() => {
  132. // TODO: ... do some needed stuff
  133. coreWorkflowChanges.value = {}
  134. updateSchemaProcessing.value = false
  135. }, 2000)
  136. }
  137. emit('changed', value, node.name)
  138. })
  139. }
  140. const localFormKitPlugins = computed(() => {
  141. return [changedValuePlugin, ...(props.formKitPlugins || [])]
  142. })
  143. const formConfig = computed(() => {
  144. return {
  145. validationVisibility: props.validationVisibility,
  146. }
  147. })
  148. // Define the additional component library for the used components which are not form fields.
  149. // Because of a typescript error, we need to cased the type: https://github.com/formkit/formkit/issues/274
  150. const additionalComponentLibrary = {
  151. FormLayout: markRaw(FormLayout) as unknown as ConcreteComponent,
  152. FormGroup: markRaw(FormGroup) as unknown as ConcreteComponent,
  153. }
  154. // Define the static schema, which will be filled with the real fields from the `schemaData`.
  155. const staticSchema: FormKitSchemaNode[] = []
  156. const schemaData = reactive<ReactiveFormSchemData>({
  157. fields: {},
  158. })
  159. const updateSchemaDataField = (field: FormSchemaField) => {
  160. const { show, props: specificProps, ...fieldProps } = field
  161. const showField = show ?? true
  162. if (schemaData.fields[field.name]) {
  163. schemaData.fields[field.name] = {
  164. show: showField,
  165. props: Object.assign(
  166. schemaData.fields[field.name].props,
  167. fieldProps,
  168. specificProps,
  169. ),
  170. }
  171. } else {
  172. schemaData.fields[field.name] = {
  173. show: showField,
  174. props: Object.assign(fieldProps, specificProps),
  175. }
  176. }
  177. }
  178. const buildStaticSchema = (schema: FormSchemaNode[]) => {
  179. const buildFormKitField = (
  180. field: FormSchemaField,
  181. ): FormKitSchemaComponent => {
  182. return {
  183. $cmp: 'FormKit',
  184. if: `$fields.${field.name}.show`,
  185. bind: `$fields.${field.name}.props`,
  186. props: {
  187. type: field.type,
  188. key: field.name,
  189. id: field.id,
  190. formId,
  191. value: props.initialValues?.[field.name] ?? field.value,
  192. },
  193. }
  194. }
  195. const getLayoutType = (
  196. layoutItem: FormSchemaLayout,
  197. ): FormKitSchemaDOMNode | FormKitSchemaComponent => {
  198. if ('component' in layoutItem) {
  199. return {
  200. $cmp: layoutItem.component,
  201. props: layoutItem.props,
  202. }
  203. }
  204. return {
  205. $el: layoutItem.element,
  206. attrs: layoutItem.attrs,
  207. }
  208. }
  209. schema.forEach((node) => {
  210. if ((node as FormSchemaLayout).isLayout) {
  211. const layoutItem = node as FormSchemaLayout
  212. if (typeof layoutItem.children === 'string') {
  213. staticSchema.push({
  214. ...getLayoutType(layoutItem),
  215. children: layoutItem.children,
  216. })
  217. } else {
  218. const childrens = layoutItem.children.map((childNode) => {
  219. if (typeof childNode === 'string') {
  220. return childNode
  221. }
  222. if ((childNode as FormSchemaLayout).isLayout) {
  223. const layoutItemChildNode = childNode as FormSchemaLayout
  224. return {
  225. ...getLayoutType(layoutItemChildNode),
  226. children: layoutItemChildNode.children as
  227. | string
  228. | FormKitSchemaNode[]
  229. | FormKitSchemaCondition,
  230. }
  231. }
  232. updateSchemaDataField(childNode as FormSchemaField)
  233. return buildFormKitField(childNode as FormSchemaField)
  234. })
  235. staticSchema.push({
  236. ...getLayoutType(layoutItem),
  237. children: childrens,
  238. })
  239. }
  240. }
  241. // At the moment we support only one level of group/list fields, no recursive implementation.
  242. else if (
  243. (node as FormSchemaGroupOrList).type === 'group' ||
  244. (node as FormSchemaGroupOrList).type === 'list'
  245. ) {
  246. const groupOrListField = node as FormSchemaGroupOrList
  247. const childrenStaticSchema: FormKitSchemaComponent[] = []
  248. groupOrListField.children.forEach((childField) => {
  249. childrenStaticSchema.push(buildFormKitField(childField))
  250. updateSchemaDataField(childField)
  251. })
  252. staticSchema.push({
  253. $cmp: 'FormKit',
  254. props: {
  255. type: groupOrListField.type,
  256. name: groupOrListField.name,
  257. key: groupOrListField.name,
  258. },
  259. children: childrenStaticSchema,
  260. })
  261. } else {
  262. const field = node as FormSchemaField
  263. staticSchema.push(buildFormKitField(field))
  264. updateSchemaDataField(field)
  265. }
  266. })
  267. }
  268. const localChangeFields = computed(() => {
  269. // if (props.formSchemaId) return coreWorkflowChanges.value
  270. return props.changeFields
  271. })
  272. watch(
  273. localChangeFields,
  274. (newChangeFields) => {
  275. Object.keys(newChangeFields).forEach((fieldName) => {
  276. const field = {
  277. ...newChangeFields[fieldName],
  278. name: fieldName,
  279. }
  280. updateSchemaDataField(field)
  281. nextTick(() => {
  282. if (field.value !== values.value[fieldName]) {
  283. formNode.value?.at(fieldName)?.input(field.value)
  284. }
  285. })
  286. })
  287. },
  288. { deep: true },
  289. )
  290. const localDisabled = computed(() => {
  291. if (props.disabled) return props.disabled
  292. return updateSchemaProcessing.value
  293. })
  294. const showInitialLoadingAnimation = ref(false)
  295. const {
  296. start: startLoadingAnimationTimeout,
  297. stop: stopLoadingAnimationTimeout,
  298. } = useTimeoutFn(
  299. () => {
  300. showInitialLoadingAnimation.value = !showInitialLoadingAnimation.value
  301. },
  302. 300,
  303. { immediate: false },
  304. )
  305. const toggleInitialLoadingAnimation = () => {
  306. stopLoadingAnimationTimeout()
  307. startLoadingAnimationTimeout()
  308. }
  309. // TODO: maybe we should react on schema changes and rebuild the static schema with a new form-id and re-rendering of
  310. // the complete form (= use the formId as the key for the whole form to trigger the re-rendering of the component...)
  311. if (props.formSchemaId) {
  312. // TODO: call the GraphQL-Query to fetch the schema.
  313. toggleInitialLoadingAnimation()
  314. new QueryHandler(
  315. useFormSchemaQuery({ formSchemaId: props.formSchemaId }),
  316. ).watchOnResult((queryResult) => {
  317. if (queryResult?.formSchema) {
  318. buildStaticSchema(queryResult.formSchema)
  319. toggleInitialLoadingAnimation()
  320. }
  321. })
  322. } else if (props.schema) {
  323. // localSchema.value = toRef(props, 'schema').value
  324. buildStaticSchema(toRef(props, 'schema').value)
  325. }
  326. </script>
  327. <template>
  328. <FormKit
  329. v-if="Object.keys(schemaData.fields).length > 0 || $slots.default"
  330. type="form"
  331. :config="formConfig"
  332. :form-class="localClass"
  333. :actions="false"
  334. :incomplete-message="true"
  335. :plugins="localFormKitPlugins"
  336. :sections-schema="formKitSectionsSchema"
  337. :disabled="localDisabled"
  338. @node="setFormNode"
  339. @submit="onSubmit"
  340. >
  341. <slot name="before-fields" />
  342. <slot
  343. name="default"
  344. :schema="staticSchema"
  345. :data="schemaData"
  346. :library="additionalComponentLibrary"
  347. >
  348. <FormKitSchema
  349. :schema="staticSchema"
  350. :data="schemaData"
  351. :library="additionalComponentLibrary"
  352. />
  353. </slot>
  354. <slot name="after-fields" />
  355. </FormKit>
  356. <div
  357. v-else-if="showInitialLoadingAnimation"
  358. class="flex items-center justify-center"
  359. >
  360. <CommonIcon name="loader" animation="spin" />
  361. </div>
  362. </template>