Browse Source

Maintenance: Desktop view - Improve error handling in the left sidebar.

Dusan Vuckovic 4 months ago
parent
commit
fbd389095f

+ 47 - 0
app/frontend/apps/desktop/components/CommonError/CommonError.vue

@@ -0,0 +1,47 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { ErrorOptions } from '#shared/router/error.ts'
+import { ErrorStatusCodes } from '#shared/types/error.ts'
+
+export interface Props {
+  options?: ErrorOptions | null
+  authenticated?: boolean
+}
+
+const props = defineProps<Props>()
+
+const errorImage = computed(() => {
+  switch (props.options?.statusCode) {
+    case ErrorStatusCodes.Forbidden:
+      return '/assets/error/error-403.svg'
+    case ErrorStatusCodes.NotFound:
+      return '/assets/error/error-404.svg'
+    case ErrorStatusCodes.InternalError:
+    default:
+      return '/assets/error/error-500.svg'
+  }
+})
+</script>
+
+<template>
+  <img width="540" class="max-h-96" :alt="$t('Error')" :src="errorImage" />
+  <h1 class="text-center text-xl leading-snug text-black dark:text-white">
+    {{ $t(options?.title) }}
+  </h1>
+  <CommonLabel class="mx-auto max-w-prose text-center" tag="p">
+    {{ $t(options?.message, ...(options?.messagePlaceholder || [])) }}
+  </CommonLabel>
+  <CommonLabel
+    v-if="options?.route"
+    class="mx-auto max-w-prose text-center"
+    tag="p"
+  >
+    {{ options.route }}
+  </CommonLabel>
+  <CommonLink v-if="!authenticated" link="/login" size="medium">
+    {{ $t('Please proceed to login') }}
+  </CommonLink>
+</template>

+ 20 - 22
app/frontend/apps/desktop/components/UserTaskbarTabs/Ticket/Ticket.vue

@@ -1,15 +1,17 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, useTemplateRef, toRef, watch } from 'vue'
+import { computed, toRef } from 'vue'
 
 import { useTicketUpdatesSubscription } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.api.ts'
 import { EnumTicketStateColorCode, type Ticket } from '#shared/graphql/types.ts'
 import SubscriptionHandler from '#shared/server/apollo/handler/SubscriptionHandler.ts'
 import { useSessionStore } from '#shared/stores/session.ts'
+import { GraphQLErrorTypes } from '#shared/types/error.ts'
 
 import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue'
 import CommonUpdateIndicator from '#desktop/components/CommonUpdateIndicator/CommonUpdateIndicator.vue'
+import { useUserTaskbarTabLink } from '#desktop/composables/useUserTaskbarTabLink.ts'
 import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts'
 import { useTicketNumber } from '#desktop/pages/ticket/composables/useTicketNumber.ts'
 
@@ -26,15 +28,14 @@ const ticketUpdatesSubscription = new SubscriptionHandler(
     ticketId: props.taskbarTab.entity!.id,
     initial: true,
   }),
+  {
+    // NB: Silence toast notifications for particular errors, these will be handled by the layout page component.
+    errorCallback: (errorHandler) =>
+      errorHandler.type !== GraphQLErrorTypes.Forbidden &&
+      errorHandler.type !== GraphQLErrorTypes.RecordNotFound,
+  },
 )
 
-const ticketLink = useTemplateRef('ticket-link')
-
-const isTicketUpdated = computed(() => {
-  if (ticketLink.value?.isExactActive) return false
-  return props.taskbarTab.notify
-})
-
 const { updateTaskbarTab } = useUserCurrentTaskbarTabsStore()
 
 const updateNotifyFlag = (notify: boolean) => {
@@ -46,6 +47,16 @@ const updateNotifyFlag = (notify: boolean) => {
   })
 }
 
+const { tabLinkInstance } = useUserTaskbarTabLink(() => {
+  // Reset the notify flag when the tab becomes active.
+  if (props.taskbarTab.notify) updateNotifyFlag(false)
+})
+
+const isTicketUpdated = computed(() => {
+  if (tabLinkInstance.value?.isExactActive) return false
+  return props.taskbarTab.notify
+})
+
 const { user } = useSessionStore()
 
 // Set the notify flag whenever the result is received from the subscription.
@@ -73,19 +84,6 @@ ticketUpdatesSubscription.onSubscribed().then(() => {
   })
 })
 
-watch(
-  () => ticketLink.value?.isExactActive,
-  (isExactActive) => {
-    if (!isExactActive) return
-
-    // Reset the notify flag when the tab becomes active.
-    if (props.taskbarTab.notify) updateNotifyFlag(false)
-
-    // Scroll the tab into view when it becomes active.
-    ticketLink.value?.$el?.scrollIntoView?.()
-  },
-)
-
 const currentState = computed(() => {
   return props.taskbarTab.entity?.state?.name || ''
 })
@@ -122,7 +120,7 @@ const currentViewTitle = computed(
 <template>
   <CommonLink
     v-if="taskbarTabLink"
-    ref="ticket-link"
+    ref="tabLinkInstance"
     v-tooltip="currentViewTitle"
     class="flex grow gap-2 rounded-md px-2 py-3 hover:no-underline focus-visible:rounded-md focus-visible:outline-none group-hover/tab:bg-blue-600 group-hover/tab:dark:bg-blue-900"
     :link="taskbarTabLink"

+ 5 - 13
app/frontend/apps/desktop/components/UserTaskbarTabs/Ticket/TicketCreate.vue

@@ -1,7 +1,7 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, useTemplateRef, watch } from 'vue'
+import { computed } from 'vue'
 
 import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
 import { useTicketCreateView } from '#shared/entities/ticket/composables/useTicketCreateView.ts'
@@ -9,6 +9,8 @@ import type { TicketCreateArticleType } from '#shared/entities/ticket/types.ts'
 import { type UserTaskbarItemEntityTicketCreate } from '#shared/graphql/types.ts'
 import { i18n } from '#shared/i18n.ts'
 
+import { useUserTaskbarTabLink } from '#desktop/composables/useUserTaskbarTabLink.ts'
+
 import type { UserTaskbarTabEntityProps } from '../types.ts'
 
 const props =
@@ -19,17 +21,7 @@ const { ticketCreateArticleType, defaultTicketCreateArticleType } =
 
 const { isTicketCustomer } = useTicketCreateView()
 
-const ticketCreateLinkInstance = useTemplateRef('link')
-
-watch(
-  () => ticketCreateLinkInstance.value?.isExactActive,
-  (isExactActive) => {
-    if (!isExactActive) return
-
-    // Scroll the tab into view when it becomes active.
-    ticketCreateLinkInstance.value?.$el?.scrollIntoView?.()
-  },
-)
+const { tabLinkInstance } = useUserTaskbarTabLink()
 
 const currentViewTitle = computed(() => {
   // Customer users should get a generic title prefix, since they cannot control the type of the first article.
@@ -69,7 +61,7 @@ const currentViewTitle = computed(() => {
 <template>
   <CommonLink
     v-if="taskbarTabLink"
-    ref="link"
+    ref="tabLinkInstance"
     v-tooltip="currentViewTitle"
     class="flex grow gap-2 rounded-md px-2 py-3 hover:no-underline focus-visible:rounded-md focus-visible:outline-none group-hover/tab:bg-blue-600 group-hover/tab:dark:bg-blue-900"
     :link="taskbarTabLink"

+ 22 - 7
app/frontend/apps/desktop/components/UserTaskbarTabs/UserTaskbarTabForbidden.vue

@@ -1,9 +1,25 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { useUserTaskbarTabLink } from '#desktop/composables/useUserTaskbarTabLink.ts'
+
+import type { UserTaskbarTabEntityProps } from './types.ts'
+
+defineProps<UserTaskbarTabEntityProps>()
+
+const { tabLinkInstance } = useUserTaskbarTabLink()
+</script>
 
 <template>
-  <div class="flex grow cursor-pointer gap-2 rounded-md px-2 py-3">
+  <CommonLink
+    v-if="taskbarTabLink"
+    ref="tabLinkInstance"
+    v-tooltip="$t('You have insufficient rights to view this object.')"
+    class="flex grow gap-2 rounded-md px-2 py-3 hover:no-underline focus-visible:rounded-md focus-visible:outline-none group-hover/tab:bg-blue-600 group-hover/tab:dark:bg-blue-900"
+    :link="taskbarTabLink"
+    exact-active-class="!bg-blue-800 text-white"
+    internal
+  >
     <CommonIcon
       name="x-lg"
       size="small"
@@ -12,16 +28,15 @@
     />
 
     <CommonLabel
-      v-tooltip="$t('You have insufficient rights to open this tab.')"
-      class="block truncate text-gray-300 dark:text-neutral-400"
+      class="-:text-gray-300 -:dark:text-neutral-400 block truncate group-hover/tab:text-white group-focus-visible/link:text-white"
     >
       {{ $t('Access denied') }}
     </CommonLabel>
-  </div>
+  </CommonLink>
 </template>
 
 <style scoped>
-.draggable span {
-  @apply text-neutral-400;
+.router-link-active span {
+  @apply text-white;
 }
 </style>

+ 22 - 7
app/frontend/apps/desktop/components/UserTaskbarTabs/UserTaskbarTabNotFound.vue

@@ -1,9 +1,25 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { useUserTaskbarTabLink } from '#desktop/composables/useUserTaskbarTabLink.ts'
+
+import type { UserTaskbarTabEntityProps } from './types.ts'
+
+defineProps<UserTaskbarTabEntityProps>()
+
+const { tabLinkInstance } = useUserTaskbarTabLink()
+</script>
 
 <template>
-  <div class="flex grow cursor-pointer gap-2 rounded-md px-2 py-3">
+  <CommonLink
+    v-if="taskbarTabLink"
+    ref="tabLinkInstance"
+    v-tooltip="$t('This object could not be found.')"
+    class="flex grow gap-2 rounded-md px-2 py-3 hover:no-underline focus-visible:rounded-md focus-visible:outline-none group-hover/tab:bg-blue-600 group-hover/tab:dark:bg-blue-900"
+    :link="taskbarTabLink"
+    exact-active-class="!bg-blue-800 text-white"
+    internal
+  >
     <CommonIcon
       name="x-lg"
       size="small"
@@ -12,16 +28,15 @@
     />
 
     <CommonLabel
-      v-tooltip="$t('This tab could not be found.')"
-      class="block truncate text-gray-300 dark:text-neutral-400"
+      class="-:text-gray-300 -:dark:text-neutral-400 block truncate group-hover/tab:text-white group-focus-visible/link:text-white"
     >
       {{ $t('Not found') }}
     </CommonLabel>
-  </div>
+  </CommonLink>
 </template>
 
 <style scoped>
-.draggable span {
-  @apply text-neutral-400;
+.router-link-active span {
+  @apply text-white;
 }
 </style>

+ 4 - 7
app/frontend/apps/desktop/components/UserTaskbarTabs/UserTaskbarTabs.vue

@@ -137,16 +137,13 @@ const getTaskbarTabLink = (tabEntityKey: string) => {
   const taskbarTab = taskbarTabListByTabEntityKey.value[tabEntityKey]
   if (!taskbarTab) return
 
-  if (
-    taskbarTab.entityAccess === EnumTaskbarEntityAccess.Forbidden ||
-    taskbarTab.entityAccess === EnumTaskbarEntityAccess.NotFound
-  )
-    return
-
   const plugin = getTaskbarTabTypePlugin(taskbarTab.type)
   if (typeof plugin.buildTaskbarTabLink !== 'function') return
 
-  return plugin.buildTaskbarTabLink(taskbarTab.entity) ?? '#'
+  return (
+    plugin.buildTaskbarTabLink(taskbarTab.entity, taskbarTab.tabEntityKey) ??
+    '#'
+  )
 }
 
 const { popover, popoverTarget, toggle, isOpen: popoverIsOpen } = usePopover()

+ 14 - 3
app/frontend/apps/desktop/components/UserTaskbarTabs/__tests__/UserTaskbarTabs.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
-import { getByRole, queryByRole } from '@testing-library/vue'
+import { getByRole } from '@testing-library/vue'
 import { type RouteRecordRaw } from 'vue-router'
 
 import {
@@ -187,6 +187,7 @@ describe('UserTaskbarTabs.vue', () => {
           key: 'Ticket-999',
           callback: EnumTaskbarEntity.TicketZoom,
           entityAccess: EnumTaskbarEntityAccess.Forbidden,
+          entity: null,
         },
       ],
     })
@@ -204,7 +205,13 @@ describe('UserTaskbarTabs.vue', () => {
       'Drag and drop to reorder your tabs.',
     )
 
-    expect(queryByRole(tab, 'link')).not.toBeInTheDocument()
+    const link = getByRole(tab, 'link')
+
+    expect(link).toHaveAttribute('href', '/desktop/tickets/999')
+
+    expect(link).toHaveAccessibleName(
+      'You have insufficient rights to view this object.',
+    )
 
     expect(
       getByRole(tab, 'button', { name: 'Close this tab' }),
@@ -220,6 +227,7 @@ describe('UserTaskbarTabs.vue', () => {
           key: 'Ticket-999',
           callback: EnumTaskbarEntity.TicketZoom,
           entityAccess: EnumTaskbarEntityAccess.NotFound,
+          entity: null,
         },
       ],
     })
@@ -237,7 +245,10 @@ describe('UserTaskbarTabs.vue', () => {
       'Drag and drop to reorder your tabs.',
     )
 
-    expect(queryByRole(tab, 'link')).not.toBeInTheDocument()
+    const link = getByRole(tab, 'link')
+
+    expect(link).toHaveAttribute('href', '/desktop/tickets/999')
+    expect(link).toHaveAccessibleName('This object could not be found.')
 
     expect(
       getByRole(tab, 'button', { name: 'Close this tab' }),

+ 5 - 2
app/frontend/apps/desktop/components/UserTaskbarTabs/plugins/ticket.ts

@@ -25,8 +25,11 @@ export default <UserTaskbarTabPlugin>{
       ticket_id: entityInternalId,
     }
   },
-  buildTaskbarTabLink: (entity?: TicketType) => {
-    if (!entity?.internalId) return
+  buildTaskbarTabLink: (entity?: TicketType, entityKey?: string) => {
+    if (!entity?.internalId) {
+      if (!entityKey) return
+      return `/tickets/${entityKey.split('-')?.[1]}`
+    }
     return `/tickets/${entity.internalId}`
   },
   confirmTabRemove: async (dirty?: boolean) => {

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

@@ -44,6 +44,7 @@ export interface UserTaskbarTabPlugin {
   ) => T
   buildTaskbarTabLink?: (
     entity?: ObjectWithId | ObjectWithUid | null,
+    entityKey?: string,
   ) => string | undefined
   confirmTabRemove?: (dirty?: boolean) => Promise<boolean>
 }

+ 66 - 2
app/frontend/apps/desktop/components/layout/LayoutPage.vue

@@ -1,19 +1,77 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { storeToRefs } from 'pinia'
+import { ref, computed } from 'vue'
+import { useRoute } from 'vue-router'
 
+import { EnumTaskbarEntityAccess } from '#shared/graphql/types.ts'
+import type { ErrorOptions } from '#shared/router/error.ts'
 import { useSessionStore } from '#shared/stores/session.ts'
+import { ErrorStatusCodes } from '#shared/types/error.ts'
 
+import CommonError from '#desktop/components/CommonError/CommonError.vue'
+import LayoutMain from '#desktop/components/layout/LayoutMain.vue'
 import LeftSidebarFooterMenu from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarFooterMenu.vue'
 import LayoutSidebar from '#desktop/components/layout/LayoutSidebar.vue'
 import PageNavigation from '#desktop/components/PageNavigation/PageNavigation.vue'
 import UserTaskbarTabs from '#desktop/components/UserTaskbarTabs/UserTaskbarTabs.vue'
 import { useResizeGridColumns } from '#desktop/composables/useResizeGridColumns.ts'
+import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts'
+
+const { activeTaskbarTabEntityAccess, activeTaskbarTabEntityKey } = storeToRefs(
+  useUserCurrentTaskbarTabsStore(),
+)
+
+const route = useRoute()
+
+const entityAccess = computed<Maybe<EnumTaskbarEntityAccess> | undefined>(
+  (currentValue) => {
+    return route.meta.taskbarTabEntityKey === activeTaskbarTabEntityKey.value
+      ? activeTaskbarTabEntityAccess.value
+      : currentValue
+  },
+)
+
+// NB: Flag in the route metadata data does not seem to trigger an update all the time.
+//   Due to this limitation, we need a way to force the re-computation in certain situations.
+const pageError = computed(() => {
+  if (!route.meta.taskbarTabEntity) return null
+
+  // Check first for page errors, when the entity access is not undefined.
+  if (entityAccess.value === undefined) return undefined
+
+  switch (entityAccess.value) {
+    case EnumTaskbarEntityAccess.Forbidden:
+      return {
+        statusCode: ErrorStatusCodes.Forbidden,
+        title: __('Forbidden'),
+        message:
+          (route.meta.messageForbidden as string) ??
+          __('You have insufficient rights to view this object.'),
+      } as ErrorOptions
+    case EnumTaskbarEntityAccess.NotFound:
+      return {
+        statusCode: ErrorStatusCodes.NotFound,
+        title: __('Not Found'),
+        message:
+          (route.meta.messageNotFound as string) ??
+          __(
+            'Object with specified ID was not found. Try checking the URL for errors.',
+          ),
+      } as ErrorOptions
+    case EnumTaskbarEntityAccess.Granted:
+    default:
+      return null
+  }
+})
 
 const noTransition = ref(false)
+
 const { userId } = useSessionStore()
+
 const storageKeyId = `${userId}-left`
+
 const {
   currentSidebarWidth,
   maxSidebarWidth,
@@ -58,7 +116,13 @@ const {
       </template>
     </LayoutSidebar>
     <div class="relative">
-      <slot><RouterView /></slot>
+      <LayoutMain
+        v-if="pageError"
+        class="flex grow flex-col items-center justify-center gap-4 bg-blue-50 dark:bg-gray-800"
+      >
+        <CommonError :options="pageError" authenticated />
+      </LayoutMain>
+      <slot v-else-if="pageError !== undefined"><RouterView /></slot>
     </div>
   </div>
 </template>

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