Browse Source

Feature: Desktop view - Show attachments in the sidebar of the ticket detail view.

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Florian Liebe 6 months ago
parent
commit
ae61f2856e

+ 1 - 4
app/controllers/attachments_controller.rb

@@ -1,8 +1,6 @@
 # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
 class AttachmentsController < ApplicationController
-  include CalendarPreview
-
   prepend_before_action :authorize!, only: %i[show destroy]
   prepend_before_action :authentication_check, except: %i[show destroy]
   prepend_before_action :authentication_check_only, only: %i[show destroy]
@@ -76,8 +74,7 @@ class AttachmentsController < ApplicationController
   private
 
   def render_calendar_preview
-    data = parse_calendar(download_file)
-    render json: data, status: :ok
+    render json: Service::Calendar::IcsFile::Parse.new(current_user:).execute(file: download_file), status: :ok
   rescue => e
     logger.error e
     render json: { error: e.message }, status: :unprocessable_entity

+ 1 - 3
app/controllers/ticket_articles_controller.rb

@@ -3,7 +3,6 @@
 class TicketArticlesController < ApplicationController
   include CreatesTicketArticles
   include ClonesTicketArticleAttachments
-  include CalendarPreview
 
   prepend_before_action -> { authorize! }, only: %i[index import_example import_start]
   prepend_before_action :authentication_check
@@ -282,8 +281,7 @@ class TicketArticlesController < ApplicationController
   private
 
   def render_calendar_preview
-    data = parse_calendar(download_file)
-    render json: data, status: :ok
+    render json: Service::Calendar::IcsFile::Parse.new(current_user:).execute(file: download_file), status: :ok
   rescue => e
     logger.error e
     render json: { error: __('The preview cannot be generated. The format is corrupted or not supported.') }, status: :unprocessable_entity

+ 104 - 0
app/frontend/apps/desktop/components/CommonCalendarPreviewFlyout/CommonCalendarPreviewFlyout.vue

@@ -0,0 +1,104 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import { getAttachmentLinks } from '#shared/composables/getAttachmentLinks.ts'
+import { getIdFromGraphQLId } from '#shared/graphql/utils.ts'
+import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
+import { useApplicationStore } from '#shared/stores/application.ts'
+import getUuid from '#shared/utils/getUuid.ts'
+import openExternalLink from '#shared/utils/openExternalLink.ts'
+
+import CommonFlyout from '#desktop/components/CommonFlyout/CommonFlyout.vue'
+import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
+import CommonSimpleTable from '#desktop/components/CommonSimpleTable/CommonSimpleTable.vue'
+import type { TableHeader } from '#desktop/components/CommonSimpleTable/types.ts'
+import { useCalendarIcsFileEventsQuery } from '#desktop/entities/calendar/ics-file/graphql/queries/events.api.ts'
+
+interface Props {
+  fileId: string
+  fileType: string
+  fileName: string
+}
+
+const props = defineProps<Props>()
+
+const calendarEventsQuery = new QueryHandler(
+  useCalendarIcsFileEventsQuery({
+    fileId: props.fileId,
+  }),
+)
+const calendarEventsQueryResult = calendarEventsQuery.result()
+const calendarEventsQueryLoading = calendarEventsQuery.loading()
+
+const tableHeaders: TableHeader[] = [
+  {
+    key: 'summary',
+    label: __('Event Summary'),
+  },
+  {
+    key: 'location',
+    label: __('Event Location'),
+  },
+  {
+    key: 'start',
+    label: __('Event Starting'),
+    type: 'timestamp_absolute',
+  },
+  {
+    key: 'end',
+    label: __('Event Ending'),
+    type: 'timestamp_absolute',
+  },
+]
+
+const tableItems = computed(() => {
+  if (!calendarEventsQueryResult.value?.calendarIcsFileEvents) return []
+
+  return calendarEventsQueryResult.value?.calendarIcsFileEvents.map(
+    (event) => ({
+      id: getUuid(),
+      summary: event.title,
+      location: event.location,
+      start: event.startDate,
+      end: event.endDate,
+    }),
+  )
+})
+
+const downloadCalendar = () => {
+  const application = useApplicationStore()
+
+  const { downloadUrl } = getAttachmentLinks(
+    {
+      internalId: getIdFromGraphQLId(props.fileId),
+      type: props.fileType,
+    },
+    application.config.api_path,
+  )
+
+  openExternalLink(downloadUrl, '_blank', props.fileName)
+}
+</script>
+
+<template>
+  <CommonFlyout
+    :header-title="__('Preview Calendar')"
+    :footer-action-options="{
+      actionLabel: __('Download'),
+      actionButton: { variant: 'primary' },
+    }"
+    name="common-calendar-preview"
+    no-close-on-action
+    @action="downloadCalendar"
+  >
+    <CommonLoader :loading="calendarEventsQueryLoading">
+      <CommonSimpleTable
+        class="mb-4 w-full"
+        :headers="tableHeaders"
+        :items="tableItems"
+      />
+    </CommonLoader>
+  </CommonFlyout>
+</template>

+ 131 - 0
app/frontend/apps/desktop/components/CommonCalendarPreviewFlyout/__tests__/CommonCalendarPreviewFlyout.spec.ts

@@ -0,0 +1,131 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '#tests/support/components/index.ts'
+import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import { mockCalendarIcsFileEventsQuery } from '#desktop/entities/calendar/ics-file/graphql/queries/events.mocks.ts'
+
+import CommonCalendarPreviewFlyout from '../CommonCalendarPreviewFlyout.vue'
+
+// FIXME: Is there any more logical way of mocking the default export and getting the mock reference back?!
+//   Note that the following does not work as it results in the following error:
+//   Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are
+//   no top level variables inside, since this call is hoisted to top of the file.
+//   Read more: https://vitest.dev/api/vi.html#vi-mock
+//   Caused by: ReferenceError: Cannot access 'openExternalLinkMock' before initialization
+// const openExternalLinkMock = vi.fn()
+
+vi.mock('#shared/utils/openExternalLink.ts', async () => ({
+  default: vi.fn(),
+}))
+
+const { default: openExternalLinkMock } = await import(
+  '#shared/utils/openExternalLink.ts'
+)
+
+const renderCommonCalendarPreviewFlyout = async (
+  props: Record<string, unknown> = {},
+  options: any = {},
+) => {
+  const result = renderComponent(CommonCalendarPreviewFlyout, {
+    props: {
+      fileId: convertToGraphQLId('Store', 1),
+      fileType: 'text/calendar',
+      fileName: 'calendar.ics',
+      ...props,
+    },
+    ...options,
+    router: true,
+    global: {
+      stubs: {
+        teleport: true,
+      },
+    },
+  })
+
+  await waitForNextTick()
+
+  return result
+}
+
+describe('TicketSidebarSharedDraftFlyout.vue', () => {
+  beforeAll(() => {
+    mockApplicationConfig({
+      api_path: '/api',
+    })
+
+    mockCalendarIcsFileEventsQuery({
+      calendarIcsFileEvents: [
+        {
+          __typename: 'CalendarIcsFileEvent',
+          title: 'event 1',
+          location: 'location 1',
+          startDate: '2024-08-22T12:00:00:00+00:00',
+          endDate: '2024-08-22T13:00:00:00+00:00',
+        },
+        {
+          __typename: 'CalendarIcsFileEvent',
+          title: 'event 2',
+          location: 'location 2',
+          startDate: '2024-08-22T14:00:00:00+00:00',
+          endDate: '2024-08-22T15:00:00:00+00:00',
+        },
+        {
+          __typename: 'CalendarIcsFileEvent',
+          title: 'event 3',
+          location: 'location 3',
+          startDate: '2024-08-22T16:00:00:00+00:00',
+          endDate: '2024-08-22T17:00:00:00+00:00',
+        },
+      ],
+    })
+  })
+
+  it('renders calendar preview', async () => {
+    const wrapper = await renderCommonCalendarPreviewFlyout()
+
+    expect(
+      wrapper.getByRole('complementary', {
+        name: 'Preview Calendar',
+      }),
+    ).toBeInTheDocument()
+
+    expect(
+      wrapper.getByRole('heading', { name: 'Preview Calendar' }),
+    ).toBeInTheDocument()
+
+    expect(wrapper.getByText('event 1')).toBeInTheDocument()
+    expect(wrapper.getByText('location 1')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 12:00')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 13:00')).toBeInTheDocument()
+
+    expect(wrapper.getByText('event 2')).toBeInTheDocument()
+    expect(wrapper.getByText('location 2')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 14:00')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 15:00')).toBeInTheDocument()
+
+    expect(wrapper.getByText('event 3')).toBeInTheDocument()
+    expect(wrapper.getByText('location 3')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 16:00')).toBeInTheDocument()
+    expect(wrapper.getByText('2024-08-22 17:00')).toBeInTheDocument()
+  })
+
+  it('supports downloading calendar', async () => {
+    const wrapper = await renderCommonCalendarPreviewFlyout()
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', {
+        name: 'Download',
+      }),
+    )
+
+    expect(openExternalLinkMock).toHaveBeenCalledWith(
+      '/api/attachments/1?disposition=attachment',
+      '_blank',
+      'calendar.ics',
+    )
+  })
+})

+ 6 - 0
app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue

@@ -103,6 +103,12 @@ const getTooltipText = (item: TableItem, header: TableHeader) => {
               class="inline text-black dark:text-white"
             >
               <template v-if="!item[header.key]">-</template>
+              <template v-else-if="header.type === 'timestamp_absolute'">
+                <CommonDateTime
+                  :date-time="item[header.key] as string"
+                  type="absolute"
+                />
+              </template>
               <template v-else-if="header.type === 'timestamp'">
                 <CommonDateTime :date-time="item[header.key] as string" />
               </template>

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

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
-type TableColumnType = 'timestamp'
+type TableColumnType = 'timestamp' | 'timestamp_absolute'
 
 export interface TableHeader<K = string> {
   key: K

+ 66 - 0
app/frontend/apps/desktop/composables/useFilePreviewViewer.ts

@@ -0,0 +1,66 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { ref } from 'vue'
+
+import { useImageViewer } from '#shared/composables/useImageViewer.ts'
+import type { FilePreview } from '#shared/utils/files.ts'
+
+import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
+
+import type { MaybeRef } from '@vueuse/shared'
+
+interface ImagePreview {
+  src?: string
+  title?: string
+}
+
+export interface ViewerFile {
+  id?: string
+  name?: string
+  content?: string
+  preview?: string
+  inline?: string
+  type?: Maybe<string>
+}
+
+export interface ViewerOptions {
+  images: ImagePreview[]
+  index: number
+  visible: boolean
+}
+
+export const imageViewerOptions = ref<ViewerOptions>({
+  visible: false,
+  index: 0,
+  images: [],
+})
+
+export const useFilePreviewViewer = (viewFiles: MaybeRef<ViewerFile[]>) => {
+  const { showImage } = useImageViewer(viewFiles)
+
+  const calendarPreviewFlyout = useFlyout({
+    name: 'common-calendar-preview',
+    component: () =>
+      import(
+        '#desktop/components/CommonCalendarPreviewFlyout/CommonCalendarPreviewFlyout.vue'
+      ),
+  })
+
+  const showPreview = (type: FilePreview, filePreviewfile: ViewerFile) => {
+    if (type === 'image') {
+      showImage(filePreviewfile)
+    }
+
+    if (type === 'calendar') {
+      calendarPreviewFlyout.open({
+        fileId: filePreviewfile.id,
+        fileType: filePreviewfile.type,
+        fileName: filePreviewfile.name,
+      })
+    }
+  }
+
+  return {
+    showPreview,
+  }
+}

+ 27 - 0
app/frontend/apps/desktop/entities/calendar/ics-file/graphql/queries/events.api.ts

@@ -0,0 +1,27 @@
+import * as Types from '#shared/graphql/types.ts';
+
+import gql from 'graphql-tag';
+import * as VueApolloComposable from '@vue/apollo-composable';
+import * as VueCompositionApi from 'vue';
+export type ReactiveFunction<TParam> = () => TParam;
+
+export const CalendarIcsFileEventsDocument = gql`
+    query calendarIcsFileEvents($fileId: ID!) {
+  calendarIcsFileEvents(fileId: $fileId) {
+    title
+    location
+    startDate
+    endDate
+    organizer
+    attendees
+    description
+  }
+}
+    `;
+export function useCalendarIcsFileEventsQuery(variables: Types.CalendarIcsFileEventsQueryVariables | VueCompositionApi.Ref<Types.CalendarIcsFileEventsQueryVariables> | ReactiveFunction<Types.CalendarIcsFileEventsQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>> = {}) {
+  return VueApolloComposable.useQuery<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>(CalendarIcsFileEventsDocument, variables, options);
+}
+export function useCalendarIcsFileEventsLazyQuery(variables?: Types.CalendarIcsFileEventsQueryVariables | VueCompositionApi.Ref<Types.CalendarIcsFileEventsQueryVariables> | ReactiveFunction<Types.CalendarIcsFileEventsQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>> = {}) {
+  return VueApolloComposable.useLazyQuery<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>(CalendarIcsFileEventsDocument, variables, options);
+}
+export type CalendarIcsFileEventsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>;

+ 11 - 0
app/frontend/apps/desktop/entities/calendar/ics-file/graphql/queries/events.graphql

@@ -0,0 +1,11 @@
+query calendarIcsFileEvents($fileId: ID!) {
+  calendarIcsFileEvents(fileId: $fileId) {
+    title
+    location
+    startDate
+    endDate
+    organizer
+    attendees
+    description
+  }
+}

+ 12 - 0
app/frontend/apps/desktop/entities/calendar/ics-file/graphql/queries/events.mocks.ts

@@ -0,0 +1,12 @@
+import * as Types from '#shared/graphql/types.ts';
+
+import * as Mocks from '#tests/graphql/builders/mocks.ts'
+import * as Operations from './events.api.ts'
+
+export function mockCalendarIcsFileEventsQuery(defaults: Mocks.MockDefaultsValue<Types.CalendarIcsFileEventsQuery, Types.CalendarIcsFileEventsQueryVariables>) {
+  return Mocks.mockGraphQLResult(Operations.CalendarIcsFileEventsDocument, defaults)
+}
+
+export function waitForCalendarIcsFileEventsQueryCalls() {
+  return Mocks.waitForGraphQLMockCalls<Types.CalendarIcsFileEventsQuery>(Operations.CalendarIcsFileEventsDocument)
+}

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