Browse Source

Desktop view: Implement auto save functionality.

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Florian Liebe 6 months ago
parent
commit
1903005129

+ 22 - 1
app/assets/javascripts/app/controllers/ticket_zoom.coffee

@@ -1203,8 +1203,21 @@ class App.TicketZoom extends App.Controller
     return {} if !App.TaskManager.get(@taskKey)
     @localTaskData = App.TaskManager.get(@taskKey).state || {}
 
+    # Set the article type_id if the type is set.
+    if @localTaskData.article && @localTaskData.article.type && !@localTaskData.article.type_id
+      @localTaskData.article.type_id = App.TicketArticleType.findByAttribute('name', @localTaskData.article.type).id
+
+    if @localTaskData.form_id
+      if !@localTaskData.article
+        @localTaskData.article = {}
+      @localTaskData.article.form_id = @localTaskData.form_id
+
+    # Remove inline images.
     if _.isObject(@localTaskData.article) && _.isArray(App.TaskManager.get(@taskKey).attachments)
-      @localTaskData.article['attachments'] = App.TaskManager.get(@taskKey).attachments
+      @localTaskData.article['attachments'] = _.filter( App.TaskManager.get(@taskKey).attachments, (attachment) ->
+        return if attachment.preferences && attachment.preferences['Content-Disposition'] is 'inline'
+        return attachment
+      )
 
     if area
       if !@localTaskData[area]
@@ -1217,6 +1230,10 @@ class App.TicketZoom extends App.Controller
   taskUpdate: (area, data) =>
     @localTaskData[area] = data
 
+    # Set the article type if the type_id is set.
+    if @localTaskData[area]['type_id'] && !@localTaskData[area]['type']
+      @localTaskData[area]['type'] = App.TicketArticleType.find(@localTaskData[area]['type_id']).name
+
     taskData = { 'state': @localTaskData }
     if _.isArray(data.attachments)
       taskData.attachments = data.attachments
@@ -1234,6 +1251,10 @@ class App.TicketZoom extends App.Controller
     @localTaskData = data
     @localTaskData.article['form_id'] = @form_id
 
+    # Set the article type if the type_id is set.
+    if @localTaskData.article['type_id'] && !@localTaskData.article['type']
+      @localTaskData.article['type'] = App.TicketArticleType.find(@localTaskData.article['type_id']).name
+
     taskData = { 'state': @localTaskData }
     if _.isArray(data.attachments)
       taskData.attachments = data.attachments

+ 1 - 12
app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee

@@ -509,18 +509,7 @@ class App.TicketZoomArticleNew extends App.Controller
               @bodyAllowNoCaption = articleType.bodyAllowNoCaption
 
     # convert remote src images to data uri
-    @$('[data-name=body] img').each( (i,image) ->
-      $image = $(image)
-      src = $image.attr('src')
-      if !_.isEmpty(src) && !src.match(/^data:image/i)
-        canvas = document.createElement('canvas')
-        canvas.width = image.width
-        canvas.height = image.height
-        ctx = canvas.getContext('2d')
-        ctx.drawImage(image, 0, 0)
-        dataURL = canvas.toDataURL()
-        $image.attr('src', dataURL)
-    )
+    App.Utils.htmlImage2DataUrlAsyncInline(@$('[data-name=body]'))
 
     @scrollToBottom() if wasScrolledToBottom
 

+ 1 - 1
app/controllers/concerns/creates_ticket_articles.rb

@@ -68,7 +68,7 @@ module CreatesTicketArticles # rubocop:disable Metrics/ModuleLength
         .new(form_id)
         .attachments
         .reject do |elem|
-          UploadCache.files_include_attachment?(attachments_inline, elem)
+          UploadCache.files_include_attachment?(attachments_inline, elem) || elem.inline?
         end
     end
 

+ 51 - 0
app/frontend/apps/desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts

@@ -0,0 +1,51 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { storeToRefs } from 'pinia'
+import { ref } from 'vue'
+
+import SubscriptionHandler from '#shared/server/apollo/handler/SubscriptionHandler.ts'
+import type { FormUpdaterOptions } from '#shared/types/form.ts'
+
+import { useUserCurrentTaskbarItemStateUpdatesSubscription } from '../graphql/subscriptions/userCurrentTaskbarItemStateUpdates.api.ts'
+import { useUserCurrentTaskbarTabsStore } from '../stores/taskbarTabs.ts'
+
+export const useTaskbarTabStateUpdates = (
+  autoSaveTriggerFormUpdater: (options?: FormUpdaterOptions) => void,
+) => {
+  const skipNextStateUpdate = ref(false)
+  const { activeTaskbarTabId } = storeToRefs(useUserCurrentTaskbarTabsStore())
+
+  const setSkipNextStateUpdate = (skip: boolean) => {
+    skipNextStateUpdate.value = skip
+  }
+
+  const stateUpdatesSubscription = new SubscriptionHandler(
+    useUserCurrentTaskbarItemStateUpdatesSubscription(
+      () => ({
+        taskbarItemId: activeTaskbarTabId.value as string,
+      }),
+      () => ({
+        enabled: !!activeTaskbarTabId.value,
+      }),
+    ),
+  )
+
+  stateUpdatesSubscription.onResult((result) => {
+    if (
+      activeTaskbarTabId.value &&
+      !skipNextStateUpdate.value &&
+      result.data?.userCurrentTaskbarItemStateUpdates.stateChanged
+    ) {
+      autoSaveTriggerFormUpdater({
+        additionalParams: {
+          taskbarId: activeTaskbarTabId.value,
+          applyTaskbarState: true,
+        },
+      })
+    }
+
+    setSkipNextStateUpdate(false)
+  })
+
+  return { setSkipNextStateUpdate }
+}

+ 5 - 0
app/frontend/apps/desktop/entities/user/current/stores/taskbarTabs.ts

@@ -221,6 +221,10 @@ export const useUserCurrentTaskbarTabsStore = defineStore(
       },
     )
 
+    const activeTaskbarTabId = computed(
+      () => activeTaskbarTab.value?.taskbarTabId,
+    )
+
     const hasTaskbarTabs = computed(() => taskbarTabList.value?.length > 0)
 
     const taskbarTabListByTabEntityKey = computed(() =>
@@ -492,6 +496,7 @@ export const useUserCurrentTaskbarTabsStore = defineStore(
 
     return {
       taskbarTabIDsInDeletion,
+      activeTaskbarTabId,
       activeTaskbarTab,
       activeTaskbarTabEntityKey,
       activeTaskbarTabContext,

+ 3 - 31
app/frontend/apps/desktop/pages/ticket/views/TicketCreate.vue

@@ -22,14 +22,13 @@ import {
   EnumTaskbarEntity,
 } from '#shared/graphql/types.ts'
 import { useWalker } from '#shared/router/walker.ts'
-import SubscriptionHandler from '#shared/server/apollo/handler/SubscriptionHandler.ts'
 import { useApplicationStore } from '#shared/stores/application.ts'
 
 import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
 import CommonContentPanel from '#desktop/components/CommonContentPanel/CommonContentPanel.vue'
 import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
 import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
-import { useUserCurrentTaskbarItemStateUpdatesSubscription } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentTaskbarItemStateUpdates.api.ts'
+import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
 import type { TaskbarTabContext } from '#desktop/entities/user/current/types.ts'
 
 import ApplyTemplate from '../components/ApplyTemplate.vue'
@@ -309,38 +308,10 @@ const tabContext = computed<TaskbarTabContext>((currentContext) => {
   return newContext
 })
 
-// TODO: create a useTicketCreateInformation-Data composable which provides/inject support
-
 const { activeTaskbarTab, activeTaskbarTabFormId, activeTaskbarTabDelete } =
   useTaskbarTab(EnumTaskbarEntity.TicketCreate, tabContext)
 
-const stateUpdatesSubscription = new SubscriptionHandler(
-  useUserCurrentTaskbarItemStateUpdatesSubscription(
-    () => ({
-      taskbarItemId: activeTaskbarTab.value?.taskbarTabId as string,
-    }),
-    () => ({
-      fetchPolicy: 'no-cache',
-      enabled: !!activeTaskbarTab.value?.taskbarTabId,
-    }),
-  ),
-)
-
-stateUpdatesSubscription.onResult((result) => {
-  const taskbarTabId = activeTaskbarTab.value?.taskbarTabId
-
-  if (
-    taskbarTabId &&
-    result.data?.userCurrentTaskbarItemStateUpdates.stateChanged
-  ) {
-    triggerFormUpdater({
-      additionalParams: {
-        taskbarId: taskbarTabId,
-        applyTaskbarState: true,
-      },
-    })
-  }
-})
+const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(triggerFormUpdater)
 
 const { waitForVariantConfirmation } = useConfirmation()
 const discardChanges = async () => {
@@ -405,6 +376,7 @@ const submitCreateTicket = async (event: FormSubmitData<TicketFormData>) => {
         use-object-attributes
         form-class="flex flex-col gap-3"
         @submit="submitCreateTicket($event as FormSubmitData<TicketFormData>)"
+        @changed="setSkipNextStateUpdate(true)"
       />
     </div>
     <template #sideBar="{ isCollapsed, toggleCollapse }">

+ 19 - 8
app/frontend/apps/desktop/pages/ticket/views/TicketDetailView.vue

@@ -42,6 +42,7 @@ import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
 import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
 import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
 import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
+import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
 
 import ArticleList from '../components/TicketDetailView/ArticleList.vue'
 import ArticleReply from '../components/TicketDetailView/ArticleReply.vue'
@@ -68,12 +69,6 @@ interface Props {
 
 const props = defineProps<Props>()
 
-const {
-  activeTaskbarTab,
-  activeTaskbarTabFormId,
-  activeTaskbarTabNewArticlePresent,
-} = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
-
 const { ticket, ticketId, canUpdateTicket, ...ticketInformation } =
   initializeTicketInformation(toRef(props, 'internalId'))
 
@@ -89,8 +84,23 @@ provide(ARTICLES_INFORMATION_KEY, {
   articlesQuery,
 })
 
-const { form, flags, isDisabled, isDirty, formNodeId, formReset, formSubmit } =
-  useForm()
+const {
+  form,
+  flags,
+  isDisabled,
+  isDirty,
+  formNodeId,
+  formReset,
+  formSubmit,
+  triggerFormUpdater,
+} = useForm()
+
+const {
+  activeTaskbarTab,
+  activeTaskbarTabFormId,
+  activeTaskbarTabNewArticlePresent,
+} = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
+const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(triggerFormUpdater)
 
 const sidebarContext = computed<TicketSidebarContext>(() => ({
   screenType: TicketSidebarScreenType.TicketDetailView,
@@ -394,6 +404,7 @@ const articleReplyPinned = useLocalStorage(
             }"
             @submit="submitEditTicket($event as FormSubmitData<TicketFormData>)"
             @settled="onEditFormSettled"
+            @changed="setSkipNextStateUpdate(true)"
           />
         </div>
       </div>

+ 38 - 23
app/frontend/shared/components/Form/Form.vue

@@ -2,7 +2,6 @@
 
 <script setup lang="ts">
 import { getNode, createMessage } from '@formkit/core'
-import { cloneAny } from '@formkit/utils'
 import { FormKit, FormKitMessages, FormKitSchema } from '@formkit/vue'
 import { refDebounced, watchOnce } from '@vueuse/shared'
 import { isEqual, cloneDeep, merge, isEmpty } from 'lodash-es'
@@ -343,17 +342,6 @@ const onSubmit = (values: FormSubmitData) => {
           formNode.value.reset()
         } else {
           formNode.value.reset(values)
-          // "dirty" check checks "_init" instead of "initial"
-          // "initial" is updated with resetValues in "reset" function, but "_init" is static
-          // TODO: keep an eye on https://github.com/formkit/formkit/issues/791
-          formNode.value.props._init = cloneAny(formNode.value.props.initial)
-          formNode.value.walk((node) => {
-            if (node.name in flatValues) {
-              node.props._init = cloneAny(flatValues[node.name])
-            } else if (node.name in values) {
-              node.props._init = cloneAny(values[node.name])
-            }
-          })
         }
         result?.()
       })
@@ -376,7 +364,7 @@ let formUpdaterQueryHandler: QueryHandler<
 >
 
 const triggerFormUpdater = (options?: FormUpdaterOptions) => {
-  handlesFormUpdater('manual', undefined, options)
+  handlesFormUpdater('manual', undefined, undefined, options)
 }
 
 const delayedSubmitPlugin = (node: FormKitNode) => {
@@ -578,7 +566,7 @@ const getResetFormValues = (
 }
 
 const resetForm = (
-  values: FormValues = {},
+  values: FormValues = props.initialValues || {},
   object: EntityObject | undefined = undefined,
   {
     resetDirty = true,
@@ -844,6 +832,7 @@ const executeFormHandler = (
   execution: FormHandlerExecution,
   currentValues: FormValues,
   changedField?: ChangedField,
+  formUpdaterData?: FormUpdaterQuery['formUpdater'],
 ) => {
   if (formHandlerExecution[execution].length === 0) return
 
@@ -862,6 +851,7 @@ const executeFormHandler = (
         values: currentValues,
         changedField,
         initialEntityObject: props.initialEntityObject,
+        formUpdaterData,
       },
     )
   })
@@ -890,6 +880,7 @@ const executeFormUpdaterRefetch = () => {
 const handlesFormUpdater = (
   trigger: FormUpdaterTrigger,
   changedField?: FormUpdaterChangedFieldInput,
+  changedFieldNode?: FormKitNode,
   options?: FormUpdaterOptions,
 ) => {
   if (!props.formUpdaterId || !formUpdaterQueryHandler) return
@@ -930,7 +921,7 @@ const handlesFormUpdater = (
     meta.dirtyFields = dirtyFields
   }
 
-  const data = {
+  const data: FormValues = {
     ...values.value,
   }
 
@@ -938,8 +929,23 @@ const handlesFormUpdater = (
     meta.reset = true
   } else if (changedField) {
     meta.changedField = changedField
-    // TODO: we need to reflect the group, when it's not flatten...
-    data[changedField.name] = changedField.newValue
+
+    const parentName = changedFieldNode?.parent?.name
+
+    // Currently we are only supporting one level.
+    if (
+      formNode.value &&
+      parentName &&
+      parentName !== formNode.value.name &&
+      (!props.flattenFormGroups ||
+        !props.flattenFormGroups.includes(parentName))
+    ) {
+      data[parentName] ||= {}
+      ;(data[parentName] as Record<string, FormFieldValue>)[changedField.name] =
+        changedField.newValue
+    } else {
+      data[changedField.name] = changedField.newValue
+    }
   }
 
   // We mark this as raw, because we want no deep reactivity on the form updater query variables.
@@ -969,11 +975,15 @@ const changedInputValueHandling = (inputNode: FormKitNode) => {
       inputNode.props.triggerFormUpdater &&
       !updaterChangedFields.has(node.name)
     ) {
-      handlesFormUpdater(inputNode.props.formUpdaterTrigger, {
-        name: node.name,
-        newValue,
-        oldValue,
-      })
+      handlesFormUpdater(
+        inputNode.props.formUpdaterTrigger,
+        {
+          name: node.name,
+          newValue,
+          oldValue,
+        },
+        node,
+      )
     }
 
     emit('changed', node.name, newValue, oldValue)
@@ -1278,7 +1288,12 @@ const initializeFormSchema = () => {
     formUpdaterQueryHandler.onResult((queryResult) => {
       // Execute the form handler function so that they can manipulate the form updater result.
       if (!formSchemaInitialized.value) {
-        executeFormHandler(FormHandlerExecution.Initial, localInitialValues)
+        executeFormHandler(
+          FormHandlerExecution.Initial,
+          localInitialValues,
+          undefined,
+          queryResult?.data?.formUpdater,
+        )
       }
 
       if (queryResult?.data?.formUpdater) {

+ 6 - 1
app/frontend/shared/components/Form/types.ts

@@ -1,7 +1,10 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
 import type { Sizes } from '#shared/components/CommonIcon/types.ts'
-import type { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
+import type {
+  EnumObjectManagerObjects,
+  FormUpdaterQuery,
+} from '#shared/graphql/types.ts'
 import type { FormUpdaterOptions } from '#shared/types/form.ts'
 import type { ObjectLike } from '#shared/types/utils.ts'
 
@@ -220,6 +223,8 @@ export interface FormHandlerFunctionData {
   values: FormValues
   changedField?: ChangedField
   initialEntityObject?: ObjectLike
+
+  formUpdaterData?: FormUpdaterQuery['formUpdater']
 }
 
 type UpdateSchemaDataFieldFunction = (

+ 5 - 1
app/frontend/shared/entities/ticket/composables/useTicketEdit.ts

@@ -41,6 +41,8 @@ export const useTicketEdit = (
       return
     }
 
+    console.log('HERE', newTicket, oldTicket)
+
     // We need only to reset the form, when really something was changed (updatedAt is not relevant for the form).
     if (
       isEqualWith(newTicket, oldTicket, (value1, value2, key) => {
@@ -50,12 +52,14 @@ export const useTicketEdit = (
       return
     }
 
+    console.log('HERE2')
+
     const ticketId = initialTicketValue.id || newTicket.id
     const { internalId: ownerInternalId } = newTicket.owner
     initialTicketValue.id = newTicket.id
     // show Zammad user as empty
     initialTicketValue.owner_id = ownerInternalId === 1 ? null : ownerInternalId
-
+    console.log('form', form.value)
     // TODO: check why article type was changed back to initial?!
     form.value?.resetForm(initialTicketValue, newTicket, {
       // don't reset to new values, if user changes something

Some files were not shown because too many files changed in this diff