Browse Source

Feature: Desktop-View - Ticket Detail View Macros

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Benjamin Scharf 5 months ago
parent
commit
631fe63b58

+ 30 - 0
app/frontend/apps/desktop/components/CommonPopoverMenu/CommonPopoverMenu.vue

@@ -79,6 +79,36 @@ const getHoverFocusStyles = (variant?: Variant) => {
         <ul role="menu" v-bind="$attrs" class="flex w-full flex-col">
           <template v-for="item in filteredMenuItems" :key="item.key">
             <li
+              v-if="'array' in item"
+              class="flex flex-col overflow-clip pt-2.5 last:rounded-b-[10px] [&:nth-child(n+2)]:border-t [&:nth-child(n+2)]:border-neutral-100 [&:nth-child(n+2)]:dark:border-gray-900"
+              role="menuitem"
+            >
+              <CommonLabel
+                size="small"
+                class="line-clamp-1 px-2 text-stone-200 dark:text-neutral-500"
+                role="heading"
+                aria-level="3"
+                >{{ item.groupLabel }}</CommonLabel
+              >
+              <template v-for="i in item.array" :key="i.key">
+                <slot :name="`item-${i.key}`" v-bind="i">
+                  <component
+                    :is="i.component || CommonPopoverMenuItem"
+                    class="flex grow p-2.5"
+                    :class="getHoverFocusStyles(i.variant)"
+                    :label="i.label"
+                    :variant="i.variant"
+                    :link="i.link"
+                    :icon="i.icon"
+                    :label-placeholder="i.labelPlaceholder"
+                    @click="onClickItem($event, i)"
+                  />
+                  <slot :name="`itemRight-${i.key}`" v-bind="i" />
+                </slot>
+              </template>
+            </li>
+            <li
+              v-else
               role="menuitem"
               class="group flex items-center justify-between last:rounded-b-[10px]"
               :class="[

+ 53 - 0
app/frontend/apps/desktop/components/CommonPopoverMenu/__tests__/CommonPopoverMenu.spec.ts

@@ -217,4 +217,57 @@ describe('rendering section', () => {
 
     expect(fn).toBeCalledWith({ id: 'example', name: 'vitest' })
   })
+
+  it('supports display of groups', () => {
+    const items = [
+      { key: 'group-test', label: 'group test', groupLabel: 'test group' },
+      {
+        key: 'group-test-2',
+        label: 'group test 2',
+        groupLabel: 'test group',
+      },
+      {
+        key: 'group-test-2',
+        label: 'group test 3',
+        groupLabel: 'test group',
+      },
+      {
+        key: 'single-group-test-2',
+        label: 'single-group test 3',
+        groupLabel: 'single group',
+      },
+      { key: 'single-test', label: 'single test' },
+    ]
+
+    const view = renderComponent(CommonPopoverMenu, {
+      shallow: false,
+      props: {
+        popover: null,
+        items,
+      },
+      router: true,
+    })
+
+    expect(view.getByRole('button', { name: 'group test' })).toBeInTheDocument()
+    expect(
+      view.getByRole('button', { name: 'group test 2' }),
+    ).toBeInTheDocument()
+    expect(
+      view.getByRole('button', { name: 'group test 3' }),
+    ).toBeInTheDocument()
+    expect(
+      view.getByRole('button', { name: 'single-group test 3' }),
+    ).toBeInTheDocument()
+    expect(
+      view.getByRole('button', { name: 'single test' }),
+    ).toBeInTheDocument()
+
+    expect(
+      view.getByRole('heading', { name: 'test group', level: 3 }),
+    ).toBeInTheDocument()
+
+    expect(
+      view.getByRole('heading', { name: 'single group', level: 3 }),
+    ).toBeInTheDocument()
+  })
 })

+ 20 - 1
app/frontend/apps/desktop/components/CommonPopoverMenu/types.ts

@@ -6,7 +6,7 @@ import type { ObjectLike } from '#shared/types/utils.ts'
 
 import { type Props as ItemProps } from './CommonPopoverMenuItem.vue'
 
-import type { Component } from 'vue'
+import type { Component, ComputedRef } from 'vue'
 
 export type Variant = ButtonVariant
 
@@ -14,9 +14,28 @@ export interface MenuItem extends ItemProps {
   key: string
   permission?: RequiredPermission
   show?: (entity?: ObjectLike) => boolean
+  /**
+   * Same group labels will be grouped together in the popover menu.
+   * Adds a separator between groups by default.
+   */
+  groupLabel?: string
   separatorTop?: boolean
   onClick?: (entity?: ObjectLike) => void
   noCloseOnClick?: boolean
   component?: Component
   variant?: Variant
 }
+
+export interface UsePopoverMenuReturn {
+  filteredMenuItems: ComputedRef<MenuItem[] | undefined>
+  singleMenuItemPresent: ComputedRef<boolean>
+  singleMenuItem: ComputedRef<MenuItem | undefined>
+}
+
+export interface GroupItem {
+  groupLabel: string
+  key: string
+  array: MenuItem[]
+}
+
+export type MenuItems = Array<MenuItem | GroupItem>

+ 27 - 9
app/frontend/apps/desktop/components/CommonPopoverMenu/usePopoverMenu.ts

@@ -4,19 +4,19 @@ import { inject, computed, provide } from 'vue'
 
 import { useSessionStore } from '#shared/stores/session.ts'
 import type { ObjectLike } from '#shared/types/utils.ts'
+import getUuid from '#shared/utils/getUuid.ts'
 
-import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
+import type {
+  GroupItem,
+  MenuItem,
+  MenuItems,
+  UsePopoverMenuReturn,
+} from '#desktop/components/CommonPopoverMenu/types.ts'
 
-import type { ComputedRef, Ref } from 'vue'
+import type { Ref } from 'vue'
 
 const POPOVER_MENU_SYMBOL = Symbol('popover-menu')
 
-interface UsePopoverMenuReturn {
-  filteredMenuItems: ComputedRef<MenuItem[] | undefined>
-  singleMenuItemPresent: ComputedRef<boolean>
-  singleMenuItem: ComputedRef<MenuItem | undefined>
-}
-
 export const usePopoverMenu = (
   items: Ref<MenuItem[] | undefined>,
   entity: Ref<ObjectLike | undefined>,
@@ -55,7 +55,25 @@ export const usePopoverMenu = (
   const filteredMenuItems = computed(() => {
     if (!items.value || !items.value.length) return
 
-    return filterItems()
+    const filteredItems = filterItems()
+
+    return filteredItems?.reduce((acc: MenuItems, item) => {
+      if (!item.groupLabel) {
+        acc.push(item)
+        return acc
+      }
+
+      const foundedItem = acc.find(
+        (group) => group.groupLabel === item.groupLabel,
+      )
+
+      const { groupLabel, ...rest } = item
+
+      if (!foundedItem) acc.push({ groupLabel, key: getUuid(), array: [rest] })
+      else (foundedItem as GroupItem).array.push(rest)
+
+      return acc
+    }, [])
   })
 
   const singleMenuItemPresent = computed(() => {

+ 11 - 0
app/frontend/apps/desktop/initializer/assets/floppy.svg

@@ -0,0 +1,11 @@
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_20_6361)">
+<path d="M11 2H9V5H11V2Z" />
+<path d="M1.5 0H13.086C13.4837 0.000350104 13.865 0.158615 14.146 0.44L15.561 1.854C15.842 2.1352 15.9999 2.51645 16 2.914V14.5C16 14.8978 15.842 15.2794 15.5607 15.5607C15.2794 15.842 14.8978 16 14.5 16H1.5C1.10218 16 0.720644 15.842 0.43934 15.5607C0.158035 15.2794 0 14.8978 0 14.5L0 1.5C0 1.10218 0.158035 0.720644 0.43934 0.43934C0.720644 0.158035 1.10218 0 1.5 0ZM1 1.5V14.5C1 14.6326 1.05268 14.7598 1.14645 14.8536C1.24021 14.9473 1.36739 15 1.5 15H2V10.5C2 10.1022 2.15804 9.72064 2.43934 9.43934C2.72064 9.15804 3.10218 9 3.5 9H12.5C12.8978 9 13.2794 9.15804 13.5607 9.43934C13.842 9.72064 14 10.1022 14 10.5V15H14.5C14.6326 15 14.7598 14.9473 14.8536 14.8536C14.9473 14.7598 15 14.6326 15 14.5V2.914C15 2.78165 14.9475 2.65471 14.854 2.561L13.439 1.146C13.3453 1.05253 13.2184 1.00003 13.086 1H13V5.5C13 5.89782 12.842 6.27936 12.5607 6.56066C12.2794 6.84196 11.8978 7 11.5 7H4.5C4.10218 7 3.72064 6.84196 3.43934 6.56066C3.15804 6.27936 3 5.89782 3 5.5V1H1.5C1.36739 1 1.24021 1.05268 1.14645 1.14645C1.05268 1.24021 1 1.36739 1 1.5ZM4 5.5C4 5.63261 4.05268 5.75979 4.14645 5.85355C4.24021 5.94732 4.36739 6 4.5 6H11.5C11.6326 6 11.7598 5.94732 11.8536 5.85355C11.9473 5.75979 12 5.63261 12 5.5V1H4V5.5ZM3 15H13V10.5C13 10.3674 12.9473 10.2402 12.8536 10.1464C12.7598 10.0527 12.6326 10 12.5 10H3.5C3.36739 10 3.24021 10.0527 3.14645 10.1464C3.05268 10.2402 3 10.3674 3 10.5V15Z" />
+</g>
+<defs>
+<clipPath id="clip0_20_6361">
+<rect width="16" height="16" />
+</clipPath>
+</defs>
+</svg>

+ 94 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailBottomBar.vue

@@ -0,0 +1,94 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { FormValues } from '#shared/components/Form/types.ts'
+import { useMacros } from '#shared/entities/macro/composables/useMacros.ts'
+import type { MacroById } from '#shared/entities/macro/types.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
+import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
+
+export interface Props {
+  dirty: boolean
+  disabled: boolean
+  formNodeId?: string
+  canUpdateTicket: boolean
+  formValues: FormValues
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  submit: [MouseEvent]
+  discard: [MouseEvent]
+  'execute-macro': [MacroById]
+}>()
+
+const groupId = computed(() =>
+  props.formValues.group_id
+    ? convertToGraphQLId('Group', props.formValues.group_id as number)
+    : undefined,
+)
+const { macros } = useMacros(groupId)
+
+const groupLabels = {
+  drafts: __('Drafts'),
+  macros: __('Macros'),
+}
+
+const actionItems = computed(() => {
+  if (!macros.value) return null
+
+  const macroMenu = macros.value.map((macro) => ({
+    key: macro.id,
+    label: macro.name,
+    groupLabel: groupLabels.macros,
+    onClick: () => emit('execute-macro', macro),
+  }))
+
+  return [
+    // :TODO add later drafts action item
+    // {
+    //   label: __('Save as draft'),
+    //   groupLabel: groupLabels.drafts,
+    //   icon: 'floppy',
+    //   key: 'macro1',
+    //   onClick: () => {},
+    // },
+    ...(groupId.value ? macroMenu : []),
+  ]
+})
+</script>
+
+<template>
+  <!--  Add live user handling-->
+  <!--  <div class="ltr:mr-auto rtl:ml-auto">live user -> COMPONENT</div>-->
+  <template v-if="canUpdateTicket">
+    <CommonButton
+      v-if="dirty"
+      size="large"
+      variant="danger"
+      :disabled="disabled"
+      @click="$emit('discard', $event)"
+      >{{ $t('Discard your unsaved changes') }}</CommonButton
+    >
+    <CommonButton
+      size="large"
+      variant="submit"
+      type="button"
+      :form="formNodeId"
+      :disabled="disabled"
+      @click="$emit('submit', $event)"
+      >{{ $t('Update') }}</CommonButton
+    >
+    <CommonActionMenu
+      v-if="actionItems"
+      no-single-action-mode
+      placement="arrowEnd"
+      :actions="actionItems"
+    />
+  </template>
+</template>

+ 1 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/useTicketEditTitle.ts

@@ -19,6 +19,7 @@ export const useTicketEditTitle = (ticket: ComputedRef<TicketById>) => {
       .send({
         ticketId: ticket.value.id,
         input: { title },
+        meta: {},
       })
       .then(() => {
         notify({

+ 254 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/__tests__/TicketDetailBottomBar.spec.ts

@@ -0,0 +1,254 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { within } from '@testing-library/vue'
+import { ref } from 'vue'
+
+import renderComponent from '#tests/support/components/renderComponent.ts'
+
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
+import {
+  mockMacrosQuery,
+  waitForMacrosQueryCalls,
+} from '#shared/graphql/queries/macros.mocks.ts'
+import { getMacrosUpdateSubscriptionHandler } from '#shared/graphql/subscriptions/macrosUpdate.mocks.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import TicketDetailBottomBar, {
+  type Props,
+} from '#desktop/pages/ticket/components/TicketDetailView/TicketDetailBottomBar.vue'
+
+vi.mock('#desktop/pages/ticket/composables/useTicketInformation.ts', () => ({
+  useTicketInformation: () => ({
+    ticket: ref(createDummyTicket()),
+  }),
+}))
+
+const renderTicketSideBarBottomBar = (props?: Partial<Props>) =>
+  renderComponent(TicketDetailBottomBar, {
+    props: {
+      disabled: false,
+      formNodeId: 'form-node-id-test',
+      dirty: false,
+      canUpdateTicket: true,
+      formValues: {
+        group_id: 2,
+      },
+      ...props,
+    },
+    store: true,
+  })
+
+describe('TicketSideBarBottomBar', () => {
+  it('renders submit button if form node id is provided', () => {
+    const wrapper = renderTicketSideBarBottomBar()
+
+    expect(wrapper.getByRole('button', { name: 'Update' })).toBeInTheDocument()
+  })
+
+  it('renders discard unsaved changes button if dirty prop is true', async () => {
+    const wrapper = renderTicketSideBarBottomBar({
+      dirty: true,
+    })
+
+    expect(
+      wrapper.queryByRole('button', { name: 'Discard your unsaved changes' }),
+    ).toBeInTheDocument()
+
+    await wrapper.rerender({
+      dirty: false,
+    })
+
+    expect(
+      wrapper.queryByRole('button', { name: 'Discard your unsaved changes' }),
+    ).not.toBeInTheDocument()
+  })
+
+  it('should disable buttons if disabled prop is true', () => {
+    const wrapper = renderTicketSideBarBottomBar({
+      dirty: true,
+      disabled: true,
+    })
+
+    expect(wrapper.getByRole('button', { name: 'Update' })).toBeDisabled()
+
+    expect(
+      wrapper.queryByRole('button', { name: 'Discard your unsaved changes' }),
+    ).toBeDisabled()
+  })
+
+  it.each(['submit', 'discard'])(
+    'emits %s event when button is clicked',
+    async (eventName) => {
+      const wrapper = renderTicketSideBarBottomBar({
+        formNodeId: 'form-node-id-test',
+        dirty: true,
+        disabled: false,
+      })
+
+      if (eventName === 'submit') {
+        await wrapper.events.click(
+          wrapper.getByRole('button', { name: 'Update' }),
+        )
+
+        expect(wrapper.emitted('submit')).toBeTruthy()
+      }
+
+      if (eventName === 'discard') {
+        await wrapper.events.click(
+          wrapper.getByRole('button', { name: 'Discard your unsaved changes' }),
+        )
+
+        expect(wrapper.emitted('discard')).toBeTruthy()
+      }
+    },
+  )
+
+  it('displays action menu button for macros for agent with update permission', async () => {
+    mockMacrosQuery({
+      macros: [
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 1),
+          active: true,
+          name: 'Macro 1',
+          uxFlowNextUp: 'next_task',
+        },
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 2),
+          active: true,
+          name: 'Macro 2',
+          uxFlowNextUp: 'next_task',
+        },
+      ],
+    })
+
+    const wrapper = renderTicketSideBarBottomBar()
+
+    const actionMenu = await wrapper.findByLabelText('Action menu button')
+
+    await wrapper.events.click(actionMenu)
+
+    const menu = await wrapper.findByRole('menu')
+
+    expect(menu).toBeInTheDocument()
+
+    expect(within(menu).getByText('Macros')).toBeInTheDocument()
+
+    expect(within(menu).getByText('Macro 1')).toBeInTheDocument()
+
+    expect(within(menu).getByText('Macro 2')).toBeInTheDocument()
+  })
+
+  it('hides action menu, submit and cancel buttons for agent without update permission', async () => {
+    const wrapper = renderTicketSideBarBottomBar({
+      canUpdateTicket: false,
+    })
+
+    expect(
+      wrapper.queryByRole('button', { name: 'Update' }),
+    ).not.toBeInTheDocument()
+
+    expect(
+      wrapper.queryByRole('button', { name: 'Discard your unsaved changes' }),
+    ).not.toBeInTheDocument()
+
+    expect(
+      wrapper.queryByLabelText('Action menu button'),
+    ).not.toBeInTheDocument()
+  })
+
+  it('reloads macro query if subscription is triggered', async () => {
+    mockMacrosQuery({
+      macros: [],
+    })
+
+    renderTicketSideBarBottomBar()
+
+    const calls = await waitForMacrosQueryCalls()
+
+    expect(calls?.at(-1)?.variables).toEqual({
+      groupId: convertToGraphQLId('Group', 2),
+    })
+
+    await getMacrosUpdateSubscriptionHandler().trigger({
+      macrosUpdate: {
+        macroUpdated: true,
+      },
+    })
+
+    mockMacrosQuery({
+      macros: [
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 1),
+          active: true,
+          name: 'Macro 1',
+          uxFlowNextUp: 'next_task',
+        },
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 2),
+          active: true,
+          name: 'Macro updated',
+          uxFlowNextUp: 'next_task',
+        },
+      ],
+    })
+
+    expect(calls.length).toBe(2)
+  })
+
+  it('submits event if macro is clicked', async () => {
+    mockMacrosQuery({
+      macros: [
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 1),
+          active: true,
+          name: 'Macro 1',
+          uxFlowNextUp: 'next_task',
+        },
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 2),
+          active: true,
+          name: 'Macro 2',
+          uxFlowNextUp: 'next_task',
+        },
+      ],
+    })
+
+    const wrapper = renderTicketSideBarBottomBar()
+
+    const actionMenu = await wrapper.findByLabelText('Action menu button')
+
+    await wrapper.events.click(actionMenu)
+
+    const menu = await wrapper.findByRole('menu')
+
+    await wrapper.events.click(within(menu).getByText('Macro 1'))
+
+    expect(wrapper.emitted('execute-macro')).toEqual([
+      [
+        {
+          __typename: 'Macro',
+          id: convertToGraphQLId('Macro', 1),
+          active: true,
+          name: 'Macro 1',
+          uxFlowNextUp: 'next_task',
+        },
+      ],
+    ])
+  })
+
+  it('hides macros if there is no group id', async () => {
+    const wrapper = renderTicketSideBarBottomBar({
+      formValues: {},
+    })
+
+    expect(
+      wrapper.queryByLabelText('Action menu button'),
+    ).not.toBeInTheDocument()
+  })
+})

+ 25 - 28
app/frontend/apps/desktop/pages/ticket/views/TicketDetailView.vue

@@ -24,6 +24,7 @@ import type { FormSubmitData } from '#shared/components/Form/types.ts'
 import { useForm } from '#shared/components/Form/useForm.ts'
 import { setErrors } from '#shared/components/Form/utils.ts'
 import { useConfirmation } from '#shared/composables/useConfirmation.ts'
+import { useTicketMacros } from '#shared/entities/macro/composables/useMacros.ts'
 import { useTicketArticleReplyAction } from '#shared/entities/ticket/composables/useTicketArticleReplyAction.ts'
 import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
 import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
@@ -38,11 +39,10 @@ import { EnumTaskbarEntity, EnumFormUpdaterId } from '#shared/graphql/types.ts'
 import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
 import { useSessionStore } from '#shared/stores/session.ts'
 
-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 TicketDetailBottomBar from '#desktop/pages/ticket/components/TicketDetailView/TicketDetailBottomBar.vue'
 
 import ArticleList from '../components/TicketDetailView/ArticleList.vue'
 import ArticleReply from '../components/TicketDetailView/ArticleReply.vue'
@@ -69,6 +69,12 @@ interface Props {
 
 const props = defineProps<Props>()
 
+const {
+  activeTaskbarTab,
+  activeTaskbarTabFormId,
+  activeTaskbarTabNewArticlePresent,
+} = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
+
 const { ticket, ticketId, canUpdateTicket, ...ticketInformation } =
   initializeTicketInformation(toRef(props, 'internalId'))
 
@@ -86,22 +92,15 @@ provide(ARTICLES_INFORMATION_KEY, {
 
 const {
   form,
+  values,
   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,
   form: form.value,
@@ -278,13 +277,18 @@ const handleUserErrorException = (exception: string) => {
     return handleIncompleteChecklist(exception)
 }
 
+const { macroId, executeMacro, resetMacroId } = useTicketMacros(formSubmit)
+
 const submitEditTicket = async (formData: FormSubmitData<TicketFormData>) => {
   const updateFormData = currentArticleType.value?.updateForm
   if (updateFormData) {
     formData = updateFormData(formData)
   }
 
-  return editTicket(formData, skipValidator.value)
+  return editTicket(formData, {
+    macroId: macroId.value,
+    skipValidator: skipValidator.value,
+  })
     .then((result) => {
       if (result?.ticketUpdate?.ticket) {
         notify({
@@ -318,6 +322,7 @@ const submitEditTicket = async (formData: FormSubmitData<TicketFormData>) => {
     })
     .finally(() => {
       skipValidator.value = undefined
+      resetMacroId()
     })
 }
 
@@ -404,7 +409,6 @@ const articleReplyPinned = useLocalStorage(
             }"
             @submit="submitEditTicket($event as FormSubmitData<TicketFormData>)"
             @settled="onEditFormSettled"
-            @changed="setSkipNextStateUpdate(true)"
           />
         </div>
       </div>
@@ -417,23 +421,16 @@ const articleReplyPinned = useLocalStorage(
       />
     </template>
     <template #bottomBar>
-      <CommonButton
-        v-if="isDirty"
-        size="large"
-        variant="danger"
+      <TicketDetailBottomBar
+        :dirty="isDirty"
         :disabled="isDisabled"
-        @click="discardChanges"
-        >{{ __('Discard your unsaved changes') }}</CommonButton
-      >
-      <CommonButton
-        size="large"
-        variant="submit"
-        type="button"
-        :form="formNodeId"
-        :disabled="isDisabled"
-        @click="checkSubmitEditTicket"
-        >{{ __('Update') }}</CommonButton
-      >
+        :form-node-id="formNodeId"
+        :can-update-ticket="canUpdateTicket"
+        :form-values="values"
+        @submit="checkSubmitEditTicket"
+        @discard="discardChanges"
+        @execute-macro="executeMacro"
+      />
     </template>
   </LayoutContent>
 </template>

+ 53 - 0
app/frontend/shared/entities/macro/composables/useMacros.ts

@@ -0,0 +1,53 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { computed, type ComputedRef, ref } from 'vue'
+
+import type { MacroById } from '#shared/entities/macro/types.ts'
+import { useMacrosQuery } from '#shared/graphql/queries/macros.api.ts'
+import { useMacrosUpdateSubscription } from '#shared/graphql/subscriptions/macrosUpdate.api.ts'
+import {
+  QueryHandler,
+  SubscriptionHandler,
+} from '#shared/server/apollo/handler/index.ts'
+
+export const useMacros = (groupId: ComputedRef<ID | undefined>) => {
+  const macroQuery = new QueryHandler(
+    useMacrosQuery(
+      () => ({
+        groupId: groupId.value as string,
+      }),
+      () => ({ enabled: !!groupId.value }),
+    ),
+  )
+
+  const macroSubscription = new SubscriptionHandler(
+    useMacrosUpdateSubscription(() => ({
+      enabled: !!groupId.value,
+    })),
+  )
+
+  macroSubscription.onResult((data) => {
+    if (data.data?.macrosUpdate.macroUpdated) macroQuery.refetch()
+  })
+
+  const result = macroQuery.result()
+
+  const macros = computed(() => result.value?.macros)
+
+  return { macros }
+}
+
+export const useTicketMacros = (formSubmit: () => void) => {
+  const macroId = ref<ID>()
+
+  const executeMacro = async (macro: MacroById) => {
+    macroId.value = macro.id
+    formSubmit()
+  }
+
+  const resetMacroId = () => {
+    macroId.value = undefined
+  }
+
+  return { macroId, executeMacro, resetMacroId }
+}

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