Просмотр исходного кода

Feature: Desktop-View: Added the possibility to use first profile sections (e.g. appearance or language).

Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Benjamin Scharf 11 месяцев назад
Родитель
Сommit
4f4265a070

+ 1 - 29
app/frontend/apps/desktop/AppDesktop.vue

@@ -2,10 +2,8 @@
 
 <script setup lang="ts">
 import useFormKitConfig from '#shared/composables/form/useFormKitConfig.ts'
-import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
 import CommonNotifications from '#shared/components/CommonNotifications/CommonNotifications.vue'
 import useAppMaintenanceCheck from '#shared/composables/useAppMaintenanceCheck.ts'
-import { useAppTheme } from '#shared/stores/theme.ts'
 import useAuthenticationChanges from '#shared/composables/authentication/useAuthenticationUpdates.ts'
 import useMetaTitle from '#shared/composables/useMetaTitle.ts'
 import usePushMessages from '#shared/composables/usePushMessages.ts'
@@ -16,8 +14,6 @@ import emitter from '#shared/utils/emitter.ts'
 import { onBeforeMount, onBeforeUnmount } from 'vue'
 import { useRouter } from 'vue-router'
 
-import LayoutSidebar from './components/layout/LayoutSidebar.vue'
-
 const router = useRouter()
 
 const authentication = useAuthenticationStore()
@@ -72,35 +68,11 @@ emitter.on('sessionInvalid', async () => {
 onBeforeUnmount(() => {
   emitter.off('sessionInvalid')
 })
-
-const appTheme = useAppTheme()
 </script>
 
 <template>
   <template v-if="application.loaded">
     <CommonNotifications />
-    <CommonButton
-      class="fixed top-2 ltr:right-2 rtl:left-2"
-      size="medium"
-      aria-label="Change theme"
-      :icon="appTheme.theme === 'light' ? 'sun' : 'moon'"
-      @click="appTheme.toggleTheme(false)"
-    />
   </template>
-  <!-- TODO: styles are placeholders -->
-  <div v-if="application.loaded" class="flex h-full">
-    <aside
-      v-if="$route.meta.sidebar !== false"
-      class="w-1/5"
-      :aria-label="__('Sidebar')"
-    >
-      <LayoutSidebar />
-    </aside>
-
-    <article
-      class="w-full h-full antialiased bg-white dark:bg-gray-500 text-gray-100 dark:text-neutral-400 overflow-hidden"
-    >
-      <RouterView />
-    </article>
-  </div>
+  <RouterView v-if="application.loaded" />
 </template>

+ 69 - 0
app/frontend/apps/desktop/components/CollapseButton/CollapseButton.vue

@@ -0,0 +1,69 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
+import { useLocaleStore } from '#shared/stores/locale.ts'
+import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
+import { EnumTextDirection } from '#shared/graphql/types.ts'
+
+const { isTouchDevice } = useTouchDevice()
+
+interface Props {
+  isCollapsed?: boolean
+  group?: string
+  orientation?: 'horizontal' | 'vertical'
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  orientation: 'horizontal',
+  isCollapsed: false,
+})
+
+defineEmits<{
+  'toggle-collapse': [MouseEvent]
+}>()
+
+const locale = useLocaleStore()
+
+const collapseButtonIcon = computed(() => {
+  if (props.orientation === 'vertical')
+    return props.isCollapsed ? 'arrows-expand' : 'arrows-collapse'
+
+  if (locale.localeData?.dir === EnumTextDirection.Rtl)
+    return props.isCollapsed ? 'arrow-bar-left' : 'arrow-bar-right'
+
+  return props.isCollapsed ? 'arrow-bar-right' : 'arrow-bar-left'
+})
+
+const parentGroupClass = computed(() => {
+  // Tailwindcss must be able to scan the class names to generate CSS
+  // https://tailwindcss.com/docs/content-configuration#dynamic-class-names
+  switch (props.group) {
+    case 'heading':
+      return 'group-hover/heading:opacity-100'
+    case 'sidebar':
+      return 'group-hover/sidebar:opacity-100'
+    default:
+      return ''
+  }
+})
+</script>
+
+<template>
+  <div>
+    <CommonButton
+      :class="[
+        { 'transition-opacity opacity-0': !isTouchDevice && parentGroupClass },
+        'focus:opacity-100',
+        parentGroupClass,
+      ]"
+      class="collapse-button"
+      :icon="collapseButtonIcon"
+      :aria-label="props.isCollapsed ? $t('expand') : $t('collapse')"
+      size="small"
+      variant="subtle"
+      @click="$emit('toggle-collapse', $event)"
+    />
+  </div>
+</template>

+ 137 - 0
app/frontend/apps/desktop/components/CollapseButton/__tests__/CollapseButton.spec.ts

@@ -0,0 +1,137 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import CollapseButton from '#desktop/components/CollapseButton/CollapseButton.vue'
+import { renderComponent } from '#tests/support/components/index.ts'
+import { useLocaleStore } from '#shared/stores/locale.ts'
+import { EnumTextDirection } from '#shared/graphql/types.ts'
+
+describe('CollapseButton', () => {
+  it.each([
+    {
+      isCollapsed: true,
+      orientation: 'horizontal',
+      icon: 'arrow-bar-right',
+    },
+    {
+      isCollapsed: false,
+      orientation: 'horizontal',
+      icon: 'arrow-bar-left',
+    },
+    {
+      isCollapsed: true,
+      orientation: 'vertical',
+      icon: 'arrows-expand',
+    },
+    {
+      isCollapsed: false,
+      orientation: 'vertical',
+      icon: 'arrows-collapse',
+    },
+  ])(
+    'displays correct LTR icon (isCollapsed: $isCollapsed, orientation: $orientation)',
+    async ({ isCollapsed, orientation, icon }) => {
+      const wrapper = renderComponent(CollapseButton, {
+        props: {
+          isCollapsed,
+          orientation,
+        },
+      })
+
+      expect(wrapper.getByIconName(icon)).toBeInTheDocument()
+    },
+  )
+
+  it.each([
+    {
+      isCollapsed: true,
+      orientation: 'horizontal',
+      icon: 'arrow-bar-left',
+    },
+    {
+      isCollapsed: false,
+      orientation: 'horizontal',
+      icon: 'arrow-bar-right',
+    },
+    {
+      isCollapsed: true,
+      orientation: 'vertical',
+      icon: 'arrows-expand',
+    },
+    {
+      isCollapsed: false,
+      orientation: 'vertical',
+      icon: 'arrows-collapse',
+    },
+  ])(
+    'displays correct RTL icon (isCollapsed: $isCollapsed, orientation: $orientation)',
+    async ({ isCollapsed, orientation, icon }) => {
+      const locale = useLocaleStore()
+
+      locale.localeData = {
+        dir: EnumTextDirection.Rtl,
+      } as any
+
+      const wrapper = renderComponent(CollapseButton, {
+        props: {
+          isCollapsed,
+          orientation,
+        },
+      })
+
+      expect(wrapper.getByIconName(icon)).toBeInTheDocument()
+    },
+  )
+
+  it('emits toggle-collapse event on click', async () => {
+    const wrapper = renderComponent(CollapseButton, {
+      props: {
+        isCollapsed: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByRole('button'))
+
+    expect(wrapper.emitted('toggle-collapse')).toBeTruthy()
+  })
+
+  it('renders the button by default', () => {
+    const wrapper = renderComponent(CollapseButton)
+
+    expect(wrapper.getByRole('button')).toBeInTheDocument()
+  })
+
+  it('shows only on hover for non-touch devices', () => {
+    const wrapper = renderComponent(CollapseButton, {
+      props: {
+        group: 'sidebar',
+      },
+    })
+    expect(wrapper.getByRole('button')).toHaveClasses([
+      'transition-opacity',
+      'opacity-0',
+    ])
+  })
+
+  it('shows always for touch devices', () => {
+    // Impersonate a touch device by mocking the corresponding media query.
+    Object.defineProperty(window, 'matchMedia', {
+      value: vi.fn().mockImplementation(() => ({
+        matches: true,
+        addEventListener: vi.fn(),
+        removeEventListener: vi.fn(),
+      })),
+    })
+
+    const wrapper = renderComponent(CollapseButton, {
+      props: {
+        group: 'test',
+      },
+    })
+
+    expect(wrapper.getByRole('button')).not.toHaveClasses([
+      'transition-opacity',
+      'opacity-0',
+      'group-hover/test:opacity-100',
+    ])
+  })
+})

+ 55 - 0
app/frontend/apps/desktop/components/CollapseButton/__tests__/useCollapseHandler.spec.ts

@@ -0,0 +1,55 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { useCollapseHandler } from '#desktop/components/CollapseButton/composables/useCollapseHandler.ts'
+import { beforeEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+describe('useCollapseHandler', async () => {
+  const emit = vi.fn()
+
+  beforeEach(() => {
+    localStorage.clear()
+  })
+
+  it('initializes with collapsed state from local storage', async () => {
+    const TestComponent = {
+      setup() {
+        const { isCollapsed } = useCollapseHandler(emit, { storageKey: 'test' })
+        expect(isCollapsed.value).toBe(false)
+      },
+      template: '<div></div>',
+    }
+    mount(TestComponent)
+  })
+
+  it('sync local storage state on initial load', async () => {
+    localStorage.setItem('test', 'true')
+    const TestComponent = {
+      setup() {
+        const { isCollapsed } = useCollapseHandler(emit, { storageKey: 'test' })
+        expect(isCollapsed.value).toBe(true)
+      },
+      template: '<div></div>',
+    }
+    mount(TestComponent)
+    await nextTick()
+    expect(emit).toHaveBeenCalledWith('collapse', true)
+  })
+
+  it('calls expand if collapse state is false', async () => {
+    const TestComponent = {
+      setup() {
+        const { toggleCollapse } = useCollapseHandler(emit, {
+          storageKey: 'test',
+        })
+        toggleCollapse()
+      },
+      template: '<div></div>',
+    }
+
+    mount(TestComponent)
+    await nextTick()
+    expect(emit).toHaveBeenCalledWith('collapse', true)
+  })
+})

+ 48 - 0
app/frontend/apps/desktop/components/CollapseButton/composables/useCollapseHandler.ts

@@ -0,0 +1,48 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+import { useLocalStorage } from '@vueuse/core'
+import { nextTick, onMounted, type Ref, ref } from 'vue'
+
+interface Emits {
+  (event: 'collapse', arg: boolean): void
+  (event: 'expand', arg: boolean): void
+}
+
+/**
+ * @args emit - The emit function from the setup function
+ * @args options.storageKey - The key to store the collapse state in local storage
+ * * */
+export const useCollapseHandler = (
+  emit: Emits,
+  options?: { storageKey: string },
+) => {
+  let isCollapsed: Ref<boolean>
+  if (options?.storageKey) {
+    isCollapsed = useLocalStorage(options.storageKey, false)
+  } else {
+    isCollapsed = ref(false)
+  }
+
+  const toggleCollapse = () => {
+    isCollapsed.value = !isCollapsed.value
+    if (isCollapsed.value) {
+      emit('collapse', true)
+    } else {
+      emit('expand', true)
+    }
+  }
+
+  onMounted(() => {
+    if (options?.storageKey) {
+      nextTick(() => {
+        // Share state on initial load
+        if (isCollapsed.value) emit('collapse', true)
+        else emit('expand', true)
+      })
+    }
+  })
+
+  return {
+    isCollapsed,
+    toggleCollapse,
+  }
+}

+ 46 - 0
app/frontend/apps/desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue

@@ -0,0 +1,46 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { useLocaleStore } from '#shared/stores/locale.ts'
+
+import type { BreadcrumbItem } from './types.ts'
+
+defineProps<{
+  items: BreadcrumbItem[]
+}>()
+
+const locale = useLocaleStore()
+</script>
+
+<template>
+  <div class="max-w-full">
+    <ul class="flex">
+      <li v-for="(item, idx) in items" :key="item.label">
+        <CommonIcon
+          v-if="item.icon"
+          :name="item.icon"
+          size="xs"
+          class="ltr:mr-1 rtl:ml-1"
+        />
+
+        <CommonLink v-if="item.route" :link="item.route" internal>
+          <CommonLabel size="large" class="hover:underline">{{
+            $t(item.label)
+          }}</CommonLabel>
+        </CommonLink>
+        <CommonLabel v-else size="large">
+          {{ $t(item.label) }}
+        </CommonLabel>
+
+        <CommonIcon
+          v-if="idx !== items.length - 1"
+          :name="
+            locale.localeData?.dir === 'rtl' ? 'chevron-left' : 'chevron-right'
+          "
+          size="xs"
+          class="inline-flex mx-1"
+        />
+      </li>
+    </ul>
+  </div>
+</template>

+ 52 - 0
app/frontend/apps/desktop/components/CommonBreadcrumb/__tests__/CommonBreadcrumb.spec.ts

@@ -0,0 +1,52 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '#tests/support/components/index.ts'
+
+import CommonBreadcrumb from '../CommonBreadcrumb.vue'
+
+describe('breadcrumb', () => {
+  it('renders the breadcrumb', async () => {
+    const view = renderComponent(CommonBreadcrumb, {
+      props: {
+        items: [
+          {
+            label: 'Dashboard',
+            route: '/',
+          },
+          {
+            label: 'Settings',
+          },
+        ],
+      },
+      router: true,
+    })
+
+    const link = view.getByRole('link')
+
+    expect(link).toHaveTextContent('Dashboard')
+    expect(link).toHaveAttribute('href', '/desktop/')
+    expect(view.getByText('Settings')).toBeInTheDocument()
+  })
+
+  it('renders icons', async () => {
+    const view = renderComponent(CommonBreadcrumb, {
+      props: {
+        items: [
+          {
+            label: 'Dashboard',
+            route: '/',
+            icon: 'eye',
+          },
+          {
+            label: 'Settings',
+          },
+        ],
+      },
+      router: true,
+    })
+
+    const icon = view.getByIconName('eye')
+
+    expect(icon).toBeInTheDocument()
+  })
+})

+ 7 - 0
app/frontend/apps/desktop/components/CommonBreadcrumb/types.ts

@@ -0,0 +1,7 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+export interface BreadcrumbItem {
+  label: string
+  route?: string
+  icon?: string
+}

+ 18 - 0
app/frontend/apps/desktop/components/CommonButton/CommonButton.vue

@@ -62,6 +62,24 @@ const variantClasses = computed(() => {
         'dark:hover:bg-red-600',
         'text-white',
       ]
+    case 'subtle':
+      return [
+        'btn-ghost',
+        'bg-blue-600',
+        'dark:bg-blue-900',
+        'hover:bg-blue-600',
+        'dark:hover:bg-blue-900',
+        'text-black',
+        'dark:text-white',
+      ]
+    case 'neutral':
+      return [
+        'btn-secondary',
+        'bg-transparent',
+        'hover:bg-transparent',
+        'text-gray-100',
+        'dark:text-neutral-400',
+      ]
     case 'secondary':
     default:
       return [

+ 8 - 0
app/frontend/apps/desktop/components/CommonButton/__tests__/CommonButton.spec.ts

@@ -88,6 +88,14 @@ describe('CommonButton.vue', () => {
       variant: 'remove',
       classes: ['btn-info'],
     },
+    {
+      variant: 'subtle',
+      classes: ['btn-ghost'],
+    },
+    {
+      variant: 'neutral',
+      classes: ['btn-secondary'],
+    },
   ])('supports $variant variant', async ({ variant, classes }) => {
     const view = renderComponent(CommonButton, {
       props: {

Некоторые файлы не были показаны из-за большого количества измененных файлов