Browse Source

Feature: Desktop view - Re-introduce full tab-switching in the new tech-stack.

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Benjamin Scharf <bs@zammad.com>
Dusan Vuckovic 3 months ago
parent
commit
3c25732f6f

+ 7 - 16
app/frontend/apps/desktop/AppDesktop.vue

@@ -12,6 +12,7 @@ import useFormKitConfig from '#shared/composables/form/useFormKitConfig.ts'
 import useAppMaintenanceCheck from '#shared/composables/useAppMaintenanceCheck.ts'
 import useMetaTitle from '#shared/composables/useMetaTitle.ts'
 import usePushMessages from '#shared/composables/usePushMessages.ts'
+import { initializeDefaultObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
 import { useApplicationStore } from '#shared/stores/application.ts'
 import { useAuthenticationStore } from '#shared/stores/authentication.ts'
 import { useLocaleStore } from '#shared/stores/locale.ts'
@@ -68,24 +69,14 @@ initializeConfirmationDialog()
 watch(
   () => session.initialized,
   (newValue, oldValue) => {
-    if (!oldValue && newValue) {
-      useUserCurrentTaskbarTabsStore()
-    }
+    if (!newValue && oldValue) return
+
+    useUserCurrentTaskbarTabsStore()
+    initializeDefaultObjectAttributes()
   },
   { immediate: true },
 )
 
-const transition = VITE_TEST_MODE
-  ? undefined
-  : {
-      enterActiveClass: 'duration-300 ease-out',
-      enterFromClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
-      enterToClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
-      leaveActiveClass: 'duration-200 ease-in',
-      leaveFromClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
-      leaveToClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
-    }
-
 onBeforeUnmount(() => {
   emitter.off('sessionInvalid')
 })
@@ -99,7 +90,7 @@ onBeforeUnmount(() => {
     </Teleport>
     <RouterView />
 
-    <DynamicInitializer name="dialog" :transition="transition" />
-    <DynamicInitializer name="flyout" :transition="transition" />
+    <DynamicInitializer name="dialog" />
+    <DynamicInitializer name="flyout" />
   </template>
 </template>

+ 15 - 1
app/frontend/apps/desktop/components/CollapseButton/__tests__/useCollapseHandler.spec.ts

@@ -34,10 +34,24 @@ describe('useCollapseHandler', async () => {
       template: '<div></div>',
     }
     mount(TestComponent)
-    await nextTick()
     expect(emit).toHaveBeenCalledWith('collapse', true)
   })
 
+  it('sync local storage state on subsequent mutations', async () => {
+    localStorage.setItem('test', 'true')
+    const TestComponent = {
+      setup() {
+        const { isCollapsed } = useCollapseHandler(emit, { storageKey: 'test' })
+        expect(isCollapsed.value).toBe(true)
+      },
+      template: '<div></div>',
+    }
+    mount(TestComponent)
+    expect(emit).toHaveBeenCalled()
+    localStorage.setItem('test', 'false')
+    expect(emit).toHaveBeenCalled()
+  })
+
   it('calls expand if collapse state is false', async () => {
     const TestComponent = {
       setup() {

+ 23 - 9
app/frontend/apps/desktop/components/CollapseButton/composables/useCollapseHandler.ts

@@ -1,6 +1,7 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
 import { useLocalStorage } from '@vueuse/core'
-import { nextTick, onMounted, type Ref, ref } from 'vue'
+import { onMounted, type Ref, ref, watch } from 'vue'
 
 interface Emits {
   (event: 'collapse', arg: boolean): void
@@ -13,9 +14,10 @@ interface Emits {
  * * */
 export const useCollapseHandler = (
   emit: Emits,
-  options?: { storageKey: string },
+  options?: { storageKey?: string },
 ) => {
   let isCollapsed: Ref<boolean>
+
   if (options?.storageKey) {
     isCollapsed = useLocalStorage(options.storageKey, false)
   } else {
@@ -24,20 +26,32 @@ export const useCollapseHandler = (
 
   const toggleCollapse = () => {
     isCollapsed.value = !isCollapsed.value
+
     if (isCollapsed.value) {
       emit('collapse', true)
-    } else {
-      emit('expand', true)
+      return
     }
+
+    emit('expand', true)
   }
 
   onMounted(() => {
+    // Set up watcher on the local storage value, so other browser tabs can sync their collapse states.
     if (options?.storageKey) {
-      nextTick(() => {
-        // Share state on initial load
-        if (isCollapsed.value) emit('collapse', true)
-        else emit('expand', true)
-      })
+      watch(
+        isCollapsed,
+        (newValue) => {
+          if (newValue) {
+            emit('collapse', true)
+            return
+          }
+
+          emit('expand', true)
+        },
+        {
+          immediate: true,
+        },
+      )
     }
   })
 

+ 31 - 17
app/frontend/apps/desktop/components/CommonConfirmationDialog/CommonConfirmationDialog.vue

@@ -12,20 +12,30 @@ import type { ConfirmationVariantOptions } from './types.ts'
 
 const { confirmationOptions } = useConfirmation()
 
+interface Props {
+  uniqueId: string
+}
+
+const props = defineProps<Props>()
+
+const currentConfirmationOptions = computed(() => {
+  return confirmationOptions.value?.get(props.uniqueId)
+})
+
 const handleConfirmation = (isCancel?: boolean) => {
   if (isCancel) {
-    confirmationOptions.value?.cancelCallback()
+    currentConfirmationOptions.value?.cancelCallback()
   } else if (isCancel === false) {
-    confirmationOptions.value?.confirmCallback()
+    currentConfirmationOptions.value?.confirmCallback()
   } else {
-    confirmationOptions.value?.closeCallback()
+    currentConfirmationOptions.value?.closeCallback()
   }
 
-  confirmationOptions.value = undefined
+  confirmationOptions.value.delete(props.uniqueId)
 }
 
 const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
-  switch (confirmationOptions.value?.confirmationVariant) {
+  switch (currentConfirmationOptions.value?.confirmationVariant) {
     case 'delete':
       return {
         headerTitle: __('Delete Object'),
@@ -33,7 +43,8 @@ const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
         content: __('Are you sure you want to delete this object?'),
         footerActionOptions: {
           actionLabel:
-            confirmationOptions.value?.buttonLabel || __('Delete Object'),
+            currentConfirmationOptions.value?.buttonLabel ||
+            __('Delete Object'),
           actionButton: {
             variant: 'danger',
           },
@@ -57,21 +68,23 @@ const confirmationVariant = computed<ConfirmationVariantOptions>(() => {
         headerTitle: __('Confirmation'),
         content: __('Do you want to continue?'),
         footerActionOptions: {
-          actionLabel: confirmationOptions.value?.buttonLabel || __('Yes'),
+          actionLabel:
+            currentConfirmationOptions.value?.buttonLabel || __('Yes'),
           actionButton: {
-            variant: confirmationOptions.value?.buttonVariant || 'primary',
+            variant:
+              currentConfirmationOptions.value?.buttonVariant || 'primary',
           },
-          cancelLabel: confirmationOptions.value?.cancelLabel,
+          cancelLabel: currentConfirmationOptions.value?.cancelLabel,
         },
       }
   }
 })
 
 const headerTitle = computed(() => {
-  if (confirmationOptions.value?.headerTitle) {
+  if (currentConfirmationOptions.value?.headerTitle) {
     return i18n.t(
-      confirmationOptions.value?.headerTitle,
-      ...(confirmationOptions.value?.headerTitlePlaceholder || []),
+      currentConfirmationOptions.value?.headerTitle,
+      ...(currentConfirmationOptions.value?.headerTitlePlaceholder || []),
     )
   }
 
@@ -81,14 +94,15 @@ const headerTitle = computed(() => {
 
 <template>
   <CommonDialog
-    name="confirmation"
+    :name="`confirmation:${props.uniqueId}`"
     :header-title="headerTitle"
     :header-icon="
-      confirmationOptions?.headerIcon || confirmationVariant.headerIcon
+      currentConfirmationOptions?.headerIcon || confirmationVariant.headerIcon
     "
-    :content="confirmationOptions?.text || confirmationVariant.content"
-    :content-placeholder="confirmationOptions?.textPlaceholder"
+    :content="currentConfirmationOptions?.text || confirmationVariant.content"
+    :content-placeholder="currentConfirmationOptions?.textPlaceholder"
     :footer-action-options="confirmationVariant.footerActionOptions"
+    global
     @close="handleConfirmation"
-  ></CommonDialog>
+  />
 </template>

+ 34 - 14
app/frontend/apps/desktop/components/CommonConfirmationDialog/__tests__/CommonConfirmationDialog.spec.ts

@@ -11,7 +11,7 @@ const { confirmationOptions } = useConfirmation()
 
 beforeAll(() => {
   const app = document.createElement('div')
-  app.id = 'app'
+  app.id = 'main-content'
   document.body.appendChild(app)
 })
 
@@ -21,20 +21,25 @@ afterAll(() => {
 
 describe('dialog confirm behaviour', () => {
   beforeEach(() => {
-    confirmationOptions.value = undefined
+    confirmationOptions.value.delete('confirmation')
   })
 
   it('renders confirmation dialog with default values', async () => {
     const confirmCallbackSpy = vi.fn()
 
-    const wrapper = renderComponent(CommonConfirmationDialog)
+    const wrapper = renderComponent(CommonConfirmationDialog, {
+      props: {
+        uniqueId: 'confirmation',
+      },
+      router: true,
+    })
 
-    confirmationOptions.value = {
+    confirmationOptions.value.set('confirmation', {
       text: 'Test heading',
       confirmCallback: confirmCallbackSpy,
       cancelCallback: vi.fn(),
       closeCallback: vi.fn(),
-    }
+    })
 
     await waitForNextTick()
 
@@ -48,14 +53,19 @@ describe('dialog confirm behaviour', () => {
   it('renders confirmation dialog with variant', async () => {
     const confirmCallbackSpy = vi.fn()
 
-    const wrapper = renderComponent(CommonConfirmationDialog)
+    const wrapper = renderComponent(CommonConfirmationDialog, {
+      props: {
+        uniqueId: 'confirmation',
+      },
+      router: true,
+    })
 
-    confirmationOptions.value = {
+    confirmationOptions.value.set('confirmation', {
       confirmationVariant: 'delete',
       confirmCallback: confirmCallbackSpy,
       cancelCallback: vi.fn(),
       closeCallback: vi.fn(),
-    }
+    })
 
     await waitForNextTick()
 
@@ -75,16 +85,21 @@ describe('dialog confirm behaviour', () => {
   it('renders confirmation dialog with custom values', async () => {
     const confirmCallbackSpy = vi.fn()
 
-    const wrapper = renderComponent(CommonConfirmationDialog)
+    const wrapper = renderComponent(CommonConfirmationDialog, {
+      props: {
+        uniqueId: 'confirmation',
+      },
+      router: true,
+    })
 
-    confirmationOptions.value = {
+    confirmationOptions.value.set('confirmation', {
       text: 'Test heading',
       buttonLabel: 'Custom button title',
       buttonVariant: 'danger',
       confirmCallback: confirmCallbackSpy,
       cancelCallback: vi.fn(),
       closeCallback: vi.fn(),
-    }
+    })
 
     await waitForNextTick()
 
@@ -100,14 +115,19 @@ describe('dialog confirm behaviour', () => {
     const confirmCallbackSpy = vi.fn()
     const cancelCallbackSpy = vi.fn()
 
-    const wrapper = renderComponent(CommonConfirmationDialog)
+    const wrapper = renderComponent(CommonConfirmationDialog, {
+      props: {
+        uniqueId: 'confirmation',
+      },
+      router: true,
+    })
 
-    confirmationOptions.value = {
+    confirmationOptions.value.set('confirmation', {
       text: 'Test heading',
       confirmCallback: confirmCallbackSpy,
       cancelCallback: cancelCallbackSpy,
       closeCallback: vi.fn(),
-    }
+    })
 
     await waitForNextTick()
 

+ 43 - 5
app/frontend/apps/desktop/components/CommonConfirmationDialog/initializeConfirmationDialog.ts

@@ -1,20 +1,58 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
-import { whenever } from '@vueuse/shared'
+import { watch } from 'vue'
+import { useRoute } from 'vue-router'
 
 import { useConfirmation } from '#shared/composables/useConfirmation.ts'
 
-import { useDialog } from '../CommonDialog/useDialog.ts'
+import { closeDialog, useDialog } from '../CommonDialog/useDialog.ts'
+
+const confirmationDialogPerRoute = new Map<string, Set<string>>()
 
 export const initializeConfirmationDialog = () => {
-  const { showConfirmation } = useConfirmation()
+  const { triggerConfirmation, lastConfirmationUuid } = useConfirmation()
+
+  const route = useRoute()
 
   const confirmationDialog = useDialog({
     name: 'confirmation',
     component: () => import('./CommonConfirmationDialog.vue'),
+    global: true,
+    afterClose: (uniqueId) => {
+      if (!uniqueId) return
+
+      const dialogs = confirmationDialogPerRoute.get(route.path)
+      if (!dialogs) return
+
+      dialogs.delete(uniqueId)
+      if (dialogs.size === 0) {
+        confirmationDialogPerRoute.delete(route.path)
+      }
+    },
   })
 
-  whenever(showConfirmation, () => {
-    confirmationDialog.open()
+  watch(triggerConfirmation, () => {
+    if (!lastConfirmationUuid.value) return
+
+    if (!confirmationDialogPerRoute.has(route.path)) {
+      confirmationDialogPerRoute.set(route.path, new Set<string>())
+    }
+    confirmationDialogPerRoute.get(route.path)!.add(lastConfirmationUuid.value)
+
+    confirmationDialog.open({
+      uniqueId: lastConfirmationUuid.value,
+    })
   })
 }
+
+export const cleanupRouteDialogs = async (routePath: string) => {
+  const dialogs = confirmationDialogPerRoute.get(routePath)
+  if (!dialogs || dialogs.size === 0) return
+
+  // Convert the set to an array, then map over it
+  const closePromises = Array.from(dialogs).map((dialogUuid) =>
+    closeDialog(`confirmation:${dialogUuid}`, true),
+  )
+
+  await Promise.all(closePromises)
+}

+ 84 - 51
app/frontend/apps/desktop/components/CommonDialog/CommonDialog.vue

@@ -2,7 +2,8 @@
 
 <script setup lang="ts">
 import { onKeyUp } from '@vueuse/core'
-import { useTemplateRef, nextTick, onMounted } from 'vue'
+import { useTemplateRef, nextTick, onMounted, computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 
 import { useTrapTab } from '#shared/composables/useTrapTab.ts'
 import stopEvent from '#shared/utils/events.ts'
@@ -31,6 +32,8 @@ export interface Props {
   // Don't focus the first element inside a Dialog after being mounted
   // if nothing is focusable, will focus "Close" button when dismissable is active.
   noAutofocus?: boolean
+  fullscreen?: boolean
+  global?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -45,13 +48,21 @@ const emit = defineEmits<{
   close: [cancel?: boolean]
 }>()
 
+const { path } = useRoute()
+
+const router = useRouter()
+
+const isActive = computed(() =>
+  props.fullscreen ? true : path === router.currentRoute.value.path,
+)
+
 const dialogElement = useTemplateRef<HTMLElement>('dialog')
 const footerElement = useTemplateRef('footer')
 const contentElement = useTemplateRef('content')
 
 const close = async (cancel?: boolean) => {
   emit('close', cancel)
-  await closeDialog(props.name)
+  await closeDialog(props.name, props.global)
 }
 
 const dialogId = `dialog-${props.name}`
@@ -78,60 +89,82 @@ onMounted(() => {
     firstFocusable?.scrollIntoView({ block: 'nearest' })
   })
 })
+
+// It is the same as flyout, but could be changed in the future?
+const transition = VITE_TEST_MODE
+  ? undefined
+  : {
+      enterActiveClass: 'duration-300 ease-out',
+      enterFromClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
+      enterToClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
+      leaveActiveClass: 'duration-200 ease-in',
+      leaveFromClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
+      leaveToClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
+    }
 </script>
 
 <template>
-  <CommonOverlayContainer
-    :id="dialogId"
-    tag="div"
-    class="fixed top-[50%] z-50 w-[500px] translate-y-[-50%] ltr:left-[50%] ltr:translate-x-[-50%] rtl:right-[50%] rtl:-translate-x-[-50%]"
-    backdrop-class="z-40"
-    role="dialog"
-    :aria-labelledby="`${dialogId}-title`"
-    @click-background="close()"
-  >
-    <component
-      :is="wrapperTag"
-      ref="dialog"
-      data-common-dialog
-      class="flex flex-col gap-3 rounded-xl border border-neutral-100 bg-neutral-50 p-3 dark:border-gray-900 dark:bg-gray-500"
-    >
-      <div
-        class="flex items-center justify-between bg-neutral-50 dark:bg-gray-500"
+  <!--  `display:none` to prevent showing up inactive dialog for cached instance -->
+  <Transition :appear="isActive" v-bind="transition">
+    <!-- We use teleport here to  center it to target node and increase z index on fullscreen to avoid clicking collapse and resize buttons -->
+    <Teleport :to="fullscreen ? '#app' : '#main-content'">
+      <CommonOverlayContainer
+        :id="dialogId"
+        tag="div"
+        disable-teleport
+        class="absolute top-[50%] z-50 h-full w-full translate-y-[-50%] ltr:left-[50%] ltr:translate-x-[-50%] rtl:right-[50%] rtl:-translate-x-[-50%]"
+        :class="{ 'z-40': fullscreen, hidden: !isActive }"
+        role="dialog"
+        backdrop-class="z-40"
+        :show-backdrop="isActive"
+        :fullscreen="fullscreen"
+        :aria-labelledby="`${dialogId}-title`"
+        @click-background="close()"
       >
-        <slot name="header">
+        <component
+          :is="wrapperTag"
+          ref="dialog"
+          data-common-dialog
+          class="!absolute top-1/2 z-50 flex w-[500px] -translate-y-1/2 flex-col gap-3 rounded-xl border border-neutral-100 bg-neutral-50 p-3 ltr:left-1/2 ltr:-translate-x-1/2 rtl:right-1/2 rtl:translate-x-1/2 dark:border-gray-900 dark:bg-gray-500"
+        >
           <div
-            class="flex items-center gap-2 text-xl leading-snug text-gray-100 dark:text-neutral-400"
+            class="flex items-center justify-between bg-neutral-50 dark:bg-gray-500"
           >
-            <CommonIcon v-if="headerIcon" size="small" :name="headerIcon" />
-            <h3 :id="`${dialogId}-title`">{{ $t(headerTitle) }}</h3>
+            <slot name="header">
+              <div
+                class="flex items-center gap-2 text-xl leading-snug text-gray-100 dark:text-neutral-400"
+              >
+                <CommonIcon v-if="headerIcon" size="small" :name="headerIcon" />
+                <h3 :id="`${dialogId}-title`">{{ $t(headerTitle) }}</h3>
+              </div>
+            </slot>
+            <CommonButton
+              class="ms-auto"
+              variant="neutral"
+              size="medium"
+              icon="x-lg"
+              :aria-label="$t('Close dialog')"
+              @click="close()"
+            />
+          </div>
+          <div ref="content" v-bind="$attrs" class="py-6 text-center">
+            <slot>
+              <CommonLabel size="large">{{
+                $t(content, ...(contentPlaceholder || []))
+              }}</CommonLabel>
+            </slot>
+          </div>
+          <div v-if="$slots.footer || !hideFooter" ref="footer">
+            <slot name="footer">
+              <CommonDialogActionFooter
+                v-bind="footerActionOptions"
+                @cancel="close(true)"
+                @action="close(false)"
+              />
+            </slot>
           </div>
-        </slot>
-        <CommonButton
-          class="ms-auto"
-          variant="neutral"
-          size="medium"
-          icon="x-lg"
-          :aria-label="$t('Close dialog')"
-          @click="close()"
-        />
-      </div>
-      <div ref="content" v-bind="$attrs" class="py-6 text-center">
-        <slot>
-          <CommonLabel size="large">{{
-            $t(content, ...(contentPlaceholder || []))
-          }}</CommonLabel>
-        </slot>
-      </div>
-      <div v-if="$slots.footer || !hideFooter" ref="footer">
-        <slot name="footer">
-          <CommonDialogActionFooter
-            v-bind="footerActionOptions"
-            @cancel="close(true)"
-            @action="close(false)"
-          />
-        </slot>
-      </div>
-    </component>
-  </CommonOverlayContainer>
+        </component>
+      </CommonOverlayContainer>
+    </Teleport>
+  </Transition>
 </template>

+ 95 - 52
app/frontend/apps/desktop/components/CommonDialog/__tests__/CommonDialog.spec.ts

@@ -1,20 +1,29 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
 import { flushPromises } from '@vue/test-utils'
-import { afterAll, beforeAll } from 'vitest'
+import { afterAll, beforeAll, expect } from 'vitest'
 
 import { renderComponent } from '#tests/support/components/index.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
 
 import CommonDialog from '../CommonDialog.vue'
-import { getDialogMeta, openDialog } from '../useDialog.ts'
+import { getDialogMeta, useDialog } from '../useDialog.ts'
 
 const html = String.raw
 
 describe('visuals for common dialog', () => {
+  let mainElement: HTMLElement
+  let app: HTMLDivElement
+
   beforeAll(() => {
-    const app = document.createElement('div')
+    app = document.createElement('div')
     app.id = 'app'
     document.body.appendChild(app)
+
+    mainElement = document.createElement('main')
+    mainElement.id = 'main-content'
+
+    app.insertAdjacentElement('beforeend', mainElement)
   })
 
   beforeEach(() => {
@@ -30,7 +39,7 @@ describe('visuals for common dialog', () => {
   })
 
   it('rendering with header title and content', () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
         headerTitle: 'Some Title',
@@ -38,28 +47,30 @@ describe('visuals for common dialog', () => {
       slots: {
         default: 'Content Slot',
       },
+      router: true,
     })
 
-    expect(view.getByText('Some Title')).toBeInTheDocument()
-    expect(view.getByText('Content Slot')).toBeInTheDocument()
-    expect(view.getByLabelText('Close dialog')).toBeInTheDocument()
+    expect(wrapper.getByText('Some Title')).toBeInTheDocument()
+    expect(wrapper.getByText('Content Slot')).toBeInTheDocument()
+    expect(wrapper.getByLabelText('Close dialog')).toBeInTheDocument()
   })
 
   it('can render header title as slot', () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
       },
       slots: {
         header: 'Some Title',
       },
+      router: true,
     })
 
-    expect(view.getByText('Some Title')).toBeInTheDocument()
+    expect(wrapper.getByText('Some Title')).toBeInTheDocument()
   })
 
   it('can close dialog with keyboard and clicks', async () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
       },
@@ -68,27 +79,28 @@ describe('visuals for common dialog', () => {
           teleport: true,
         },
       },
+      router: true,
     })
 
     await flushPromises()
 
-    await view.events.keyboard('{Escape}')
+    await wrapper.events.keyboard('{Escape}')
 
-    const emitted = view.emitted()
+    const emitted = wrapper.emitted()
 
     expect(emitted.close).toHaveLength(1)
     expect(emitted.close[0]).toEqual([undefined])
 
-    await view.events.click(view.getByLabelText('Close dialog'))
+    await wrapper.events.click(wrapper.getByLabelText('Close dialog'))
     expect(emitted.close).toHaveLength(2)
 
-    await view.events.click(view.getByRole('button', { name: 'OK' }))
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'OK' }))
     expect(emitted.close).toHaveLength(3)
     expect(emitted.close[2]).toEqual([false])
   })
 
   it('rendering different footer button content', () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
         headerTitle: 'Some Title',
@@ -99,22 +111,24 @@ describe('visuals for common dialog', () => {
       slots: {
         default: 'Content Slot',
       },
+      router: true,
     })
 
     expect(
-      view.getByRole('button', { name: 'Yes, continue' }),
+      wrapper.getByRole('button', { name: 'Yes, continue' }),
     ).toBeInTheDocument()
   })
 
   it('has an accessible name', async () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         headerTitle: 'foobar',
         name: 'dialog',
       },
+      router: true,
     })
 
-    expect(view.getByRole('dialog')).toHaveAccessibleName('foobar')
+    expect(wrapper.getByRole('dialog')).toHaveAccessibleName('foobar')
   })
 
   it('traps focus inside the dialog', async () => {
@@ -125,7 +139,7 @@ describe('visuals for common dialog', () => {
     `
     document.body.appendChild(externalForm)
 
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
       },
@@ -138,32 +152,35 @@ describe('visuals for common dialog', () => {
           </select>
         `,
       },
+      router: true,
     })
 
-    view.getByTestId('input').focus()
-    expect(view.getByTestId('input')).toHaveFocus()
+    wrapper.getByTestId('input').focus()
+    expect(wrapper.getByTestId('input')).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByTestId('div')).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(wrapper.getByTestId('div')).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByTestId('select')).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(wrapper.getByTestId('select')).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByRole('button', { name: 'Cancel & Go Back' })).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(
+      wrapper.getByRole('button', { name: 'Cancel & Go Back' }),
+    ).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByRole('button', { name: 'OK' })).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(wrapper.getByRole('button', { name: 'OK' })).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByLabelText('Close dialog')).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(wrapper.getByLabelText('Close dialog')).toHaveFocus()
 
-    await view.events.keyboard('{Tab}')
-    expect(view.getByTestId('input')).toHaveFocus()
+    await wrapper.events.keyboard('{Tab}')
+    expect(wrapper.getByTestId('input')).toHaveFocus()
   })
 
   it('autofocuses the first focusable element', async () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
       },
@@ -175,15 +192,16 @@ describe('visuals for common dialog', () => {
           </select>
         `,
       },
+      router: true,
     })
 
     await flushPromises()
 
-    expect(view.getByTestId('div')).toHaveFocus()
+    expect(wrapper.getByTestId('div')).toHaveFocus()
   })
 
   it('focuses close, if there is nothing focusable in dialog', async () => {
-    const view = renderComponent(CommonDialog, {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
         hideFooter: true,
@@ -192,35 +210,60 @@ describe('visuals for common dialog', () => {
 
     await flushPromises()
 
-    expect(view.getByLabelText('Close dialog')).toHaveFocus()
+    expect(wrapper.getByLabelText('Close dialog')).toHaveFocus()
   })
 
   it('refocuses element that opened dialog', async () => {
-    const button = document.createElement('button')
-    button.setAttribute('aria-haspopup', 'dialog')
-    button.setAttribute('aria-controls', 'dialog-dialog')
-    button.setAttribute('data-test-id', 'button')
-    document.body.appendChild(button)
+    const wrapper = renderComponent(
+      {
+        template: `
+          <button aria-haspopup="dialog" aria-controls="dialog" data-test-id="button"/>`,
+        setup() {
+          const dialog = useDialog({
+            name: 'dialog',
+            component: () =>
+              import('#desktop/components/CommonDialog/CommonDialog.vue'),
+          })
+          dialog.open({ name: 'dialog', hideFooter: true })
+        },
+      },
+      {
+        dialog: true,
+        router: true,
+      },
+    )
+
+    await wrapper.events.click(wrapper.getByTestId('button'))
+
+    expect(wrapper.getByTestId('button')).toHaveFocus()
 
-    button.focus()
+    await wrapper.events.keyboard('{Escape}')
 
-    expect(button).toHaveFocus()
+    await waitForNextTick()
 
-    await openDialog('dialog', {})
+    expect(wrapper.getByTestId('button')).toHaveFocus()
+  })
 
-    const view = renderComponent(CommonDialog, {
+  it('displays by default over the main content', () => {
+    const wrapper = renderComponent(CommonDialog, {
       props: {
         name: 'dialog',
-        hideFooter: true,
+        fullscreen: false,
       },
     })
 
-    await flushPromises()
-
-    expect(view.getByLabelText('Close dialog')).toHaveFocus()
+    expect(mainElement.children).not.include(wrapper.baseElement)
+    expect(app.children).include(wrapper.baseElement)
+  })
 
-    await view.events.keyboard('{Escape}')
+  it('supports displaying over entire viewport', () => {
+    const wrapper = renderComponent(CommonDialog, {
+      props: {
+        name: 'dialog',
+        fullscreen: true,
+      },
+    })
 
-    expect(button).toHaveFocus()
+    expect(mainElement.children).include(wrapper.baseElement)
   })
 })

+ 30 - 3
app/frontend/apps/desktop/components/CommonDialog/useDialog.ts

@@ -1,5 +1,7 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
+import { useRoute } from 'vue-router'
+
 import {
   closeOverlayContainer,
   getOpenedOverlayContainers,
@@ -9,6 +11,7 @@ import {
   useOverlayContainer,
   type OverlayContainerOptions,
 } from '#desktop/composables/useOverlayContainer.ts'
+import { getCurrentApp } from '#desktop/currentApp.ts'
 
 const OVERLAY_CONTAINER_TYPE = 'dialog'
 
@@ -30,10 +33,34 @@ export const getDialogMeta = () => {
 export const openDialog = async (
   name: string,
   props: Record<string, unknown>,
-) => openOverlayContainer(OVERLAY_CONTAINER_TYPE, name, props)
+  global: boolean = false,
+) => {
+  let currentName = name
+
+  if (!global) {
+    getCurrentApp().runWithContext(() => {
+      const route = useRoute()
+
+      currentName = `${name}_${route.path}`
+    })
+  }
+
+  return openOverlayContainer(OVERLAY_CONTAINER_TYPE, currentName, props)
+}
 
-export const closeDialog = async (name: string) =>
-  closeOverlayContainer(OVERLAY_CONTAINER_TYPE, name)
+export const closeDialog = async (name: string, global: boolean = false) => {
+  let currentName = name
+
+  if (!global) {
+    getCurrentApp().runWithContext(() => {
+      const route = useRoute()
+
+      currentName = `${name}_${route.path}`
+    })
+  }
+
+  return closeOverlayContainer(OVERLAY_CONTAINER_TYPE, currentName)
+}
 
 export const useDialog = (options: OverlayContainerOptions) => {
   return useOverlayContainer(OVERLAY_CONTAINER_TYPE, options)

+ 0 - 2
app/frontend/apps/desktop/components/CommonDropdown/__tests__/CommonDropdown.spec.ts

@@ -46,8 +46,6 @@ describe('CommonDropdown', () => {
 
     expect(await wrapper.findByRole('menu')).toBeInTheDocument()
 
-    console.log(wrapper.html())
-
     await wrapper.events.click(
       wrapper.getByRole('button', { name: dropdownItems[0].label }),
     )

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