Browse Source

Feature: Desktop View - Change ticket customer action

Benjamin Scharf 5 months ago
parent
commit
a7dc18f1fa

+ 2 - 4
app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue

@@ -34,13 +34,11 @@ import type { ActionFooterOptions, FlyoutSizes } from './types.ts'
 
 export interface Props {
   /**
-   * @property name
    * Unique name which gets used to identify the flyout
    * @example 'crop-avatar'
    */
   name: string
   /**
-   * @property persistResizeWidth
    * If true, the given flyout resizable width will be stored in local storage
    * Stored under the key `flyout-${name}-width`
    * @example 'crop-avatar' => 'flyout-crop-avatar-width'
@@ -57,9 +55,8 @@ export interface Props {
   footerActionOptions?: ActionFooterOptions
   noCloseOnAction?: boolean
   /**
-   * @property noAutofocus
    * Don't focus the first element inside a Flyout after being mounted
-   * if nothing is focusable, will focus "Close" button when dismissable is active.
+   * if nothing is focusable, will focus "Close" button when dismissible is active.
    */
   noAutofocus?: boolean
 }
@@ -80,6 +77,7 @@ const emit = defineEmits<{
 
 const close = async (isCancel?: boolean) => {
   emit('close', isCancel)
+
   await closeFlyout(props.name)
 }
 

+ 5 - 5
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/TopBarHeader/TicketInformation.vue

@@ -36,10 +36,10 @@ const { updateTitle } = useTicketEditTitle(ticketId)
         :entity="ticket.customer"
       />
       <CommonOrganizationAvatar
-        v-if="ticket.customer?.organization"
+        v-if="ticket.organization"
         class="ltr:-translate-x- -z-10 ltr:-translate-x-1.5 rtl:translate-x-1.5"
         :size="hideDetails ? 'medium' : 'normal'"
-        :entity="ticket.customer.organization"
+        :entity="ticket.organization"
       />
     </div>
 
@@ -56,13 +56,13 @@ const { updateTitle } = useTicketEditTitle(ticketId)
             class="flex items-center gap-1"
             :class="{
               'after:inline-block after:h-[.12rem] after:w-[.12rem] after:shrink-0 after:rounded-full after:bg-current':
-                ticket.customer?.organization,
+                ticket.organization,
             }"
           >
             {{ ticket.customer.fullname }}
           </CommonLabel>
-          <CommonLabel v-if="ticket.customer.organization?.name">
-            {{ ticket.customer.organization?.name }}
+          <CommonLabel v-if="ticket.organization?.name">
+            {{ ticket.organization?.name }}
           </CommonLabel>
         </div>
 

+ 75 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketDetailView/actions/TicketChangeCustomer/TicketChangeCustomerFlyout.vue

@@ -0,0 +1,75 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { toRef } from 'vue'
+
+import Form from '#shared/components/Form/Form.vue'
+import type { FormSubmitData } from '#shared/components/Form/types.ts'
+import { useForm } from '#shared/components/Form/useForm.ts'
+import { useTicketChangeCustomer } from '#shared/entities/ticket/composables/useTicketChangeCustomer.ts'
+import { useTicketFormOrganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
+import type {
+  TicketById,
+  TicketCustomerUpdateFormData,
+} from '#shared/entities/ticket/types.ts'
+import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
+import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
+
+import CommonFlyout from '#desktop/components/CommonFlyout/CommonFlyout.vue'
+import { closeFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
+
+interface Props {
+  ticket: TicketById
+  name: string
+}
+
+const props = defineProps<Props>()
+
+const { form } = useForm()
+
+const formSchema = defineFormSchema([
+  {
+    name: 'customer_id',
+    screen: 'edit',
+    object: EnumObjectManagerObjects.Ticket,
+    required: true,
+  },
+  {
+    name: 'organization_id',
+    screen: 'edit',
+    object: EnumObjectManagerObjects.Ticket,
+  },
+])
+
+const { changeCustomer } = useTicketChangeCustomer(toRef(props, 'ticket'), {
+  onSuccess: () => closeFlyout(props.name),
+})
+</script>
+
+<template>
+  <CommonFlyout
+    :header-title="__('Change Customer')"
+    header-icon="person"
+    name="change-customer"
+    no-close-on-action
+    :footer-action-options="{
+      form,
+      actionButton: {
+        type: 'submit',
+      },
+    }"
+  >
+    <Form
+      id="form-change-customer"
+      ref="form"
+      should-autofocus
+      :handlers="[useTicketFormOrganizationHandler()]"
+      :initial-entity-object="ticket"
+      use-object-attributes
+      :schema="formSchema"
+      @submit="
+        changeCustomer($event as FormSubmitData<TicketCustomerUpdateFormData>)
+      "
+    />
+  </CommonFlyout>
+</template>

+ 68 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketDetailView/actions/TicketChangeCustomer/__tests__/TicketChangeCustomerFlyout.spec.ts

@@ -0,0 +1,68 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import '#tests/graphql/builders/mocks.ts'
+
+import { renderComponent } from '#tests/support/components/index.ts'
+
+import {
+  mockAutocompleteSearchGenericQuery,
+  waitForAutocompleteSearchGenericQueryCalls,
+} from '#shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/generic.mocks.ts'
+import { waitForTicketCustomerUpdateMutationCalls } from '#shared/entities/ticket/graphql/mutations/customerUpdate.mocks.ts'
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import { testOptions } from '#desktop/components/Form/fields/FieldCustomer/__tests__/support/testOptions.ts'
+
+import TicketChangeCustomerFlyout from '../TicketChangeCustomerFlyout.vue'
+
+describe('TicketChangeCustomerFlyout', () => {
+  it('updates customer information.', async () => {
+    const wrapper = renderComponent(TicketChangeCustomerFlyout, {
+      props: {
+        ticket: createDummyTicket(),
+        name: 'create-customer',
+      },
+      flyout: true,
+      store: true,
+      form: true,
+    })
+
+    expect(
+      wrapper.getByRole('heading', { name: 'Change Customer', level: 2 }),
+    ).toBeInTheDocument()
+
+    expect(wrapper.getByIconName('person')).toBeInTheDocument()
+
+    expect(await wrapper.findByLabelText('Customer')).toBeInTheDocument()
+
+    expect(wrapper.getByLabelText('Nicole Braun')).toBeInTheDocument()
+
+    mockAutocompleteSearchGenericQuery({
+      autocompleteSearchGeneric: testOptions,
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Customer'))
+
+    expect(wrapper.getByRole('menu')).toBeInTheDocument()
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    await wrapper.events.type(filterElement, 'zammad')
+
+    await waitForAutocompleteSearchGenericQueryCalls()
+
+    await wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Update' }))
+
+    const calls = await waitForTicketCustomerUpdateMutationCalls()
+
+    expect(calls.at(-1)?.variables).toEqual({
+      input: {
+        customerId: convertToGraphQLId('User', 2),
+      },
+      ticketId: convertToGraphQLId('Ticket', 1),
+    })
+  })
+})

+ 35 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketDetailView/actions/TicketChangeCustomer/useChangeCustomerMenuItem.ts

@@ -0,0 +1,35 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
+
+import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
+import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
+import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
+
+const { open } = useFlyout({
+  name: 'change-customer',
+  component: () => import('./TicketChangeCustomerFlyout.vue'),
+})
+
+export const useChangeCustomerMenuItem = () => {
+  const { ticket } = useTicketInformation()
+  const { isTicketAgent, isTicketEditable } = useTicketView(ticket)
+
+  const FLYOUT_KEY = 'change-customer'
+
+  const customerChangeMenuItem: MenuItem = {
+    key: FLYOUT_KEY,
+    label: __('Change customer'),
+    icon: 'person',
+    show: () => isTicketAgent.value && isTicketEditable.value,
+    onClick: () => {
+      open({
+        ticket,
+        name: FLYOUT_KEY,
+      })
+    },
+  }
+  return {
+    customerChangeMenuItem,
+  }
+}

+ 20 - 13
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue

@@ -1,6 +1,8 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { computed } from 'vue'
+
 import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
 import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
 import type {
@@ -16,7 +18,11 @@ import CommonSimpleEntityList from '#desktop/components/CommonSimpleEntityList/C
 import { EntityType } from '#desktop/components/CommonSimpleEntityList/types.ts'
 import NavigationMenuList from '#desktop/components/NavigationMenu/NavigationMenuList.vue'
 import { NavigationMenuDensity } from '#desktop/components/NavigationMenu/types.ts'
-import type { TicketSidebarContentProps } from '#desktop/pages/ticket/types/sidebar.ts'
+import { useChangeCustomerMenuItem } from '#desktop/pages/ticket/components/TicketSidebar/TicketDetailView/actions/TicketChangeCustomer/useChangeCustomerMenuItem.ts'
+import {
+  type TicketSidebarContentProps,
+  TicketSidebarScreenType,
+} from '#desktop/pages/ticket/types/sidebar.ts'
 
 import TicketSidebarContent from '../TicketSidebarContent.vue'
 
@@ -28,23 +34,24 @@ interface Props extends TicketSidebarContentProps {
   objectAttributes: ObjectManagerFrontendAttribute[]
 }
 
-defineProps<Props>()
+const props = defineProps<Props>()
 
 defineEmits<{
   'load-more-secondary-organizations': []
 }>()
 
-const actions: MenuItem[] = [
-  {
-    key: 'change-customer',
-    label: __('Edit Customer'),
-    icon: 'person-gear',
-    show: (entity) => entity?.policy.update,
-    onClick: (id) => {
-      console.log(id, 'Edit customer')
-    },
-  },
-]
+const actions = computed<MenuItem[]>(() => {
+  const availableActions: MenuItem[] = []
+
+  // :TODO find a better way to split this up maybe on plugin level
+  // :TODO find a way to provide the ticket via prop
+  if (props.context.screenType === TicketSidebarScreenType.TicketDetailView) {
+    const { customerChangeMenuItem } = useChangeCustomerMenuItem()
+    availableActions.push(customerChangeMenuItem)
+  }
+
+  return availableActions // ADD the rest available menu actions
+})
 </script>
 
 <template>

+ 29 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/__tests__/TicketSidebarCustomer.spec.ts

@@ -1,14 +1,20 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
+import { cleanup } from '@testing-library/vue'
+import { computed, ref } from 'vue'
+
 import { renderComponent } from '#tests/support/components/index.ts'
 import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
 import { waitForNextTick } from '#tests/support/utils.ts'
 
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
 import {
   mockUserQuery,
   waitForUserQueryCalls,
 } from '#shared/entities/user/graphql/queries/user.mocks.ts'
 
+import { TICKET_KEY } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
+
 import { TicketSidebarScreenType } from '../../../../types/sidebar.ts'
 import customerSidebarPlugin from '../../plugins/customer.ts'
 import TicketSidebarCustomer from '../TicketSidebarCustomer.vue'
@@ -30,6 +36,20 @@ const renderTicketSidebarCustomer = async (
       },
     },
     router: true,
+    plugins: [
+      (app) => {
+        const ticket = createDummyTicket()
+        app.provide(TICKET_KEY, {
+          ticketId: computed(() => ticket.id),
+          ticket: computed(() => ticket),
+          form: ref(),
+          showTicketArticleReplyForm: () => {},
+          isTicketEditable: computed(() => true),
+          newTicketArticlePresent: ref(false),
+          ticketInternalId: computed(() => ticket.internalId),
+        })
+      },
+    ],
     global: {
       stubs: {
         teleport: true,
@@ -46,6 +66,15 @@ const renderTicketSidebarCustomer = async (
 }
 
 describe('TicketSidebarCustomer.vue', () => {
+  afterEach(() => {
+    // :TODO write a cleanup inside of the renderComponent to avoid
+    // :ERROR App already provides property with key "Symbol(ticket)". It will be overwritten with the new value
+    // Missing cleanup in test env
+    // It is still getting logged as warnings
+    cleanup()
+    vi.clearAllMocks()
+  })
+
   it('shows sidebar when customer ID is present', async () => {
     const wrapper = await renderTicketSidebarCustomer({
       formValues: {

+ 126 - 47
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/__tests__/TicketSidebarCustomerContent.spec.ts

@@ -1,10 +1,17 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
+import { cleanup } from '@testing-library/vue'
+import { computed, ref } from 'vue'
+
 import { renderComponent } from '#tests/support/components/index.ts'
+import { mockPermissions } from '#tests/support/mock-permissions.ts'
 import { waitForNextTick } from '#tests/support/utils.ts'
 
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
 import { convertToGraphQLId } from '#shared/graphql/utils.ts'
 
+import { TICKET_KEY } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
+
 import { TicketSidebarScreenType } from '../../../../types/sidebar.ts'
 import customerSidebarPlugin from '../../plugins/customer.ts'
 import TicketSidebarCustomerContent from '../TicketSidebarCustomerContent.vue'
@@ -61,9 +68,14 @@ const mockedUser = {
     update: true,
   },
 }
-
-const renderTicketSidebarCustomerContent = async (options: any = {}) => {
-  const result = renderComponent(TicketSidebarCustomerContent, {
+const defaultTicket = createDummyTicket()
+
+const renderTicketSidebarCustomerContent = async (
+  screen: TicketSidebarScreenType = TicketSidebarScreenType.TicketCreate,
+  ticket = defaultTicket,
+  options: any = {},
+) =>
+  renderComponent(TicketSidebarCustomerContent, {
     props: {
       sidebarPlugin: customerSidebarPlugin,
       customer: mockedUser,
@@ -84,72 +96,139 @@ const renderTicketSidebarCustomerContent = async (options: any = {}) => {
         },
       ],
       context: {
-        screenType: TicketSidebarScreenType.TicketCreate,
+        screenType: screen,
       },
     },
+    plugins: [
+      (app) => {
+        app.provide(TICKET_KEY, {
+          ticketId: computed(() => ticket.id),
+          ticket: computed(() => ticket),
+          form: ref(),
+          showTicketArticleReplyForm: () => {},
+          isTicketEditable: computed(() => true),
+          newTicketArticlePresent: ref(false),
+          ticketInternalId: computed(() => ticket.internalId),
+        })
+      },
+    ],
     router: true,
     ...options,
   })
 
-  return result
-}
-
 describe('TicketSidebarCustomerContent.vue', () => {
-  it('renders customer info', async () => {
-    const wrapper = await renderTicketSidebarCustomerContent()
+  afterEach(() => {
+    // :TODO write a cleanup inside of the renderComponent to avoid
+    // :ERROR App already provides property with key "Symbol(ticket)". It will be overwritten with the new value
+    // Missing cleanup in test env
+    // It is still getting logged as warnings
+    cleanup()
+    vi.clearAllMocks()
+  })
 
-    await waitForNextTick()
+  beforeEach(() => {
+    mockPermissions(['ticket.agent'])
+  })
 
-    expect(wrapper.getByRole('heading', { level: 2 })).toHaveTextContent(
-      'Customer',
-    )
+  describe('ticket-create-screen', () => {
+    it('renders customer info', async () => {
+      const wrapper = await renderTicketSidebarCustomerContent()
+
+      await waitForNextTick()
+
+      expect(wrapper.getByRole('heading', { level: 2 })).toHaveTextContent(
+        'Customer',
+      )
 
-    expect(
-      wrapper.getByRole('button', { name: 'Action menu button' }),
-    ).toBeInTheDocument()
+      // :TODO currently we don't have an available actions
+      // For example customer change is logically not available in ticket create
+      expect(
+        wrapper.queryByRole('button', { name: 'Action menu button' }),
+      ).not.toBeInTheDocument()
 
-    expect(
-      wrapper.getByRole('img', { name: 'Avatar (Nicole Braun)' }),
-    ).toHaveTextContent('NB')
+      expect(
+        wrapper.getByRole('img', { name: 'Avatar (Nicole Braun)' }),
+      ).toHaveTextContent('NB')
 
-    expect(wrapper.getByText('Nicole Braun')).toBeInTheDocument()
+      expect(wrapper.getByText('Nicole Braun')).toBeInTheDocument()
 
-    expect(
-      wrapper.getByRole('link', { name: 'Zammad Foundation' }),
-    ).toHaveAttribute('href', '/organizations/1')
+      expect(
+        wrapper.getByRole('link', { name: 'Zammad Foundation' }),
+      ).toHaveAttribute('href', '/organizations/1')
 
-    expect(wrapper.getByText('Email')).toBeInTheDocument()
+      expect(wrapper.getByText('Email')).toBeInTheDocument()
 
-    expect(
-      wrapper.getByRole('link', { name: 'nicole.braun@zammad.org' }),
-    ).toBeInTheDocument()
+      expect(
+        wrapper.getByRole('link', { name: 'nicole.braun@zammad.org' }),
+      ).toBeInTheDocument()
 
-    expect(wrapper.getByText('Secondary organizations')).toBeInTheDocument()
+      expect(wrapper.getByText('Secondary organizations')).toBeInTheDocument()
 
-    expect(
-      wrapper.getByRole('link', { name: 'Avatar (Zammad Org) Zammad Org' }),
-    ).toHaveAttribute('href', '/organizations/2')
+      expect(
+        wrapper.getByRole('link', { name: 'Avatar (Zammad Org) Zammad Org' }),
+      ).toHaveAttribute('href', '/organizations/2')
 
-    expect(
-      wrapper.getByRole('link', { name: 'Avatar (Zammad Inc) Zammad Inc' }),
-    ).toHaveAttribute('href', '/organizations/3')
+      expect(
+        wrapper.getByRole('link', { name: 'Avatar (Zammad Inc) Zammad Inc' }),
+      ).toHaveAttribute('href', '/organizations/3')
 
-    expect(
-      wrapper.getByRole('link', { name: 'Avatar (Zammad Ltd) Zammad Ltd' }),
-    ).toHaveAttribute('href', '/organizations/4')
+      expect(
+        wrapper.getByRole('link', { name: 'Avatar (Zammad Ltd) Zammad Ltd' }),
+      ).toHaveAttribute('href', '/organizations/4')
 
-    expect(
-      wrapper.getByRole('button', { name: 'Show 2 more' }),
-    ).toBeInTheDocument()
+      expect(
+        wrapper.getByRole('button', { name: 'Show 2 more' }),
+      ).toBeInTheDocument()
 
-    expect(wrapper.getByText('Tickets')).toBeInTheDocument()
+      expect(wrapper.getByText('Tickets')).toBeInTheDocument()
 
-    expect(
-      wrapper.getByRole('link', { name: 'open tickets 42' }),
-    ).toBeInTheDocument()
+      expect(
+        wrapper.getByRole('link', { name: 'open tickets 42' }),
+      ).toBeInTheDocument()
+
+      expect(
+        wrapper.getByRole('link', { name: 'closed tickets 10' }),
+      ).toBeInTheDocument()
+    })
+  })
+
+  describe('ticket-detail-screen', () => {
+    it.each(['Change customer'])(
+      'shows button for `%s` action',
+      async (buttonLabel) => {
+        const wrapper = await renderTicketSidebarCustomerContent(
+          TicketSidebarScreenType.TicketDetailView,
+        )
+
+        await wrapper.events.click(
+          wrapper.getByRole('button', {
+            name: 'Action menu button',
+          }),
+        )
+
+        expect(
+          await wrapper.findByRole('button', { name: buttonLabel }),
+        ).toBeInTheDocument()
+      },
+    )
+
+    it('does not show `Change customer` action if user is agent and has no update permission', async () => {
+      mockPermissions(['ticket.agent'])
+
+      const wrapper = await renderTicketSidebarCustomerContent(
+        TicketSidebarScreenType.TicketDetailView,
+        {
+          ...defaultTicket,
+          policy: {
+            update: false,
+            agentReadAccess: true,
+          },
+        },
+      )
 
-    expect(
-      wrapper.getByRole('link', { name: 'closed tickets 10' }),
-    ).toBeInTheDocument()
+      expect(
+        wrapper.queryByRole('button', { name: 'Action menu button' }),
+      ).not.toBeInTheDocument()
+    })
   })
 })

+ 31 - 4
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue

@@ -1,27 +1,54 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { computed } from 'vue'
+
 import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
 
+import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
 import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
+import { useChangeCustomerMenuItem } from '#desktop/pages/ticket/components/TicketSidebar/TicketDetailView/actions/TicketChangeCustomer/useChangeCustomerMenuItem.ts'
 import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
-import type { TicketSidebarContentProps } from '#desktop/pages/ticket/types/sidebar.ts'
+import {
+  type TicketSidebarContentProps,
+  TicketSidebarScreenType,
+} from '#desktop/pages/ticket/types/sidebar.ts'
 
 import TicketSidebarContent from '../TicketSidebarContent.vue'
 
 import TicketTags from './TicketSidebarInformationContent/TicketTags.vue'
 
-defineProps<TicketSidebarContentProps>()
+const props = defineProps<TicketSidebarContentProps>()
 
 const { ticket } = useTicketInformation()
 
 const { isTicketAgent, isTicketEditable } = useTicketView(ticket)
+
+const actions = computed<MenuItem[]>(() => {
+  const availableActions: MenuItem[] = []
+
+  // :TODO find a better way to split this up maybe on plugin level
+  // :TODO find a way to provide the ticket via prop
+  if (props.context.screenType === TicketSidebarScreenType.TicketDetailView) {
+    const { customerChangeMenuItem } = useChangeCustomerMenuItem()
+    availableActions.push(customerChangeMenuItem)
+  }
+
+  return availableActions // ADD the rest available menu actions
+})
 </script>
 
 <template>
-  <TicketSidebarContent :title="sidebarPlugin.title" :icon="sidebarPlugin.icon">
+  <TicketSidebarContent
+    :title="sidebarPlugin.title"
+    :icon="sidebarPlugin.icon"
+    :actions="actions"
+  >
     <CommonSectionCollapse id="ticket-attributes" :title="__('Attributes')">
-      <div id="ticketEditAttributeForm"></div>
+      <div
+        id="ticketEditAttributeForm"
+        data-test-id="ticket-edit-attribute-form"
+      />
     </CommonSectionCollapse>
 
     <CommonSectionCollapse

+ 156 - 0
app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/__tests__/TicketSidebarInformationContent.spec.ts

@@ -0,0 +1,156 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { cleanup } from '@testing-library/vue'
+import { afterEach } from 'vitest'
+import { computed, ref } from 'vue'
+
+import renderComponent from '#tests/support/components/renderComponent.ts'
+import { mockPermissions } from '#tests/support/mock-permissions.ts'
+
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
+
+import plugin from '#desktop/pages/ticket/components/TicketSidebar/plugins/information.ts'
+import TicketSidebarInformationContent from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue'
+import { TICKET_KEY } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
+import { TicketSidebarScreenType } from '#desktop/pages/ticket/types/sidebar.ts'
+
+const defaultTicket = createDummyTicket()
+
+const renderInformationSidebar = (ticket = defaultTicket) =>
+  renderComponent(TicketSidebarInformationContent, {
+    props: {
+      context: {
+        screenType: TicketSidebarScreenType.TicketDetailView,
+      },
+      sidebarPlugin: plugin,
+    },
+    form: true,
+    router: true,
+    plugins: [
+      (app) => {
+        app.provide(TICKET_KEY, {
+          ticketId: computed(() => ticket.id),
+          ticket: computed(() => ticket),
+          form: ref(),
+          showTicketArticleReplyForm: () => {},
+          isTicketEditable: computed(() => true),
+          newTicketArticlePresent: ref(false),
+          ticketInternalId: computed(() => ticket.internalId),
+        })
+      },
+    ],
+  })
+
+describe('TicketSidebarInformationContent', () => {
+  describe('actions', () => {
+    afterEach(() => {
+      // :TODO write a cleanup inside of the renderComponent to avoid
+      // :ERROR App already provides property with key "Symbol(ticket)". It will be overwritten with the new value
+      // Missing cleanup in test env
+      // It is still getting logged as warnings
+      cleanup()
+      vi.clearAllMocks()
+    })
+
+    it('displays basic sidebar content', () => {
+      mockPermissions(['ticket.agent'])
+
+      const wrapper = renderInformationSidebar()
+
+      expect(
+        wrapper.getByRole('heading', { name: 'Ticket', level: 2 }),
+      ).toBeInTheDocument()
+
+      expect(wrapper.getByIconName('chat-left-text'))
+    })
+
+    it('contains teleport target element for ticket edit attribute form', () => {
+      mockPermissions(['ticket.agent'])
+
+      const wrapper = renderInformationSidebar()
+
+      expect(
+        wrapper.getByRole('heading', { name: 'Attributes', level: 3 }),
+      ).toBeInTheDocument()
+
+      expect(wrapper.getByTestId('ticket-edit-attribute-form')).toHaveAttribute(
+        'id',
+        'ticketEditAttributeForm',
+      )
+    })
+
+    it('displays tags and heading', () => {
+      mockPermissions(['ticket.agent'])
+
+      const wrapper = renderInformationSidebar({
+        ...defaultTicket,
+        tags: ['tag1', 'tag2'],
+      })
+
+      expect(
+        wrapper.getByRole('heading', { name: 'Tags', level: 3 }),
+      ).toBeInTheDocument()
+
+      expect(wrapper.getByRole('link', { name: 'tag1' })).toBeInTheDocument()
+
+      // :TODO adjust link as soon as we have correct value for search
+      expect(wrapper.getByRole('link', { name: 'tag2' })).toHaveAttribute(
+        'href',
+        '#',
+      )
+    })
+
+    it.each(['Change customer'])(
+      'shows button for `%s` action',
+      async (buttonLabel) => {
+        mockPermissions(['ticket.agent'])
+
+        const wrapper = renderInformationSidebar()
+
+        await wrapper.events.click(
+          wrapper.getByRole('button', {
+            name: 'Action menu button',
+          }),
+        )
+
+        expect(
+          await wrapper.findByRole('button', { name: buttonLabel }),
+        ).toBeInTheDocument()
+      },
+    )
+
+    it('does not show customer change action if agent has no update permission', () => {
+      mockPermissions(['ticket.agent'])
+
+      const wrapper = renderInformationSidebar({
+        ...defaultTicket,
+        policy: {
+          update: false,
+          agentReadAccess: true,
+        },
+      })
+
+      // :TODO adjust as soon as we have more actions
+      expect(
+        wrapper.queryByRole('button', { name: 'Action menu button' }),
+      ).not.toBeInTheDocument()
+    })
+
+    it('does not show `Customer change` action if user is customer', () => {
+      mockPermissions(['ticket.customer'])
+
+      const wrapper = renderInformationSidebar({
+        ...defaultTicket,
+        policy: {
+          update: true,
+          agentReadAccess: false,
+        },
+      })
+
+      // :TODO adjust as soon as we have more actions
+      expect(
+        wrapper.queryByRole('button', { name: 'Action menu button' }),
+      ).not.toBeInTheDocument()
+    })
+  })
+})

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