Browse Source

Maintenance: Update LayoutHeader to include more dynamic slot usage

Benjamin Scharf 11 months ago
parent
commit
2c77470c80

+ 1 - 3
app/frontend/apps/mobile/components/CommonBackButton/CommonBackButton.vue

@@ -21,9 +21,7 @@ const props = defineProps<Props>()
 const walker = useWalker()
 
 const isHomeButton = computed(() => {
-  if (props.avoidHomeButton || walker.getBackUrl(props.fallback) !== '/')
-    return false
-  return true
+  return !(props.avoidHomeButton || walker.getBackUrl(props.fallback) !== '/')
 })
 
 const locale = useLocaleStore()

+ 2 - 10
app/frontend/apps/mobile/components/CommonButton/CommonButton.vue

@@ -3,17 +3,9 @@
 <script setup lang="ts">
 import { computed } from 'vue'
 import { startCase } from 'lodash-es'
-import type { ButtonVariant } from '#shared/components/Form/fields/FieldButton/types.ts'
+import type { CommonButtonProps } from '#mobile/components/CommonButton/types.ts'
 
-interface Props {
-  form?: string
-  type?: 'button' | 'reset' | 'submit'
-  disabled?: boolean
-  variant?: ButtonVariant
-  transparentBackground?: boolean
-}
-
-const props = withDefaults(defineProps<Props>(), {
+const props = withDefaults(defineProps<CommonButtonProps>(), {
   type: 'button',
   variant: 'secondary',
 })

+ 11 - 0
app/frontend/apps/mobile/components/CommonButton/types.ts

@@ -0,0 +1,11 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import type { ButtonVariant } from '#shared/components/Form/fields/FieldButton/types.ts'
+
+export interface CommonButtonProps {
+  form?: string
+  type?: 'button' | 'reset' | 'submit'
+  disabled?: boolean
+  variant?: ButtonVariant
+  transparentBackground?: boolean
+}

+ 3 - 0
app/frontend/apps/mobile/components/CommonRefetch/CommonRefetch.vue

@@ -7,6 +7,8 @@ const props = defineProps<{
   refetch: boolean
 }>()
 
+defineOptions({ inheritAttrs: false })
+
 const refetching = ref(false)
 
 let timeout: number
@@ -35,6 +37,7 @@ watch(
   >
     <div
       v-if="refetching"
+      v-bind="$attrs"
       class="absolute items-center justify-center"
       role="status"
     >

+ 51 - 27
app/frontend/apps/mobile/components/layout/LayoutHeader.vue

@@ -1,22 +1,27 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
+import CommonBackButton from '#mobile/components/CommonBackButton/CommonBackButton.vue'
+import CommonRefetch from '#mobile/components/CommonRefetch/CommonRefetch.vue'
+import { computed, ref, useSlots } from 'vue'
 import type { RouteLocationRaw } from 'vue-router'
-import CommonButton from '../CommonButton/CommonButton.vue'
-import CommonBackButton from '../CommonBackButton/CommonBackButton.vue'
-import CommonRefetch from '../CommonRefetch/CommonRefetch.vue'
+import type { CommonButtonProps } from '#mobile/components/CommonButton/types.ts'
 
 export interface Props {
+  containerTag?: 'header' | 'div'
   title?: string
   titleClass?: string
   backTitle?: string
   backIgnore?: string[]
   backUrl?: RouteLocationRaw
   backAvoidHomeButton?: boolean
+  defaultAttrs?: Record<string, unknown>
   refetch?: boolean
   actionTitle?: string
   actionHidden?: boolean
+  actionBtnProps?: CommonButtonProps
+
   onAction?(): void
 }
 
@@ -28,7 +33,11 @@ defineExpose({
 
 const props = withDefaults(defineProps<Props>(), {
   refetch: false,
+  containerTag: 'header',
 })
+const slots = useSlots()
+
+const hasSlots = computed(() => Object.keys(slots).length > 0)
 
 const headerClass = computed(() => {
   return [
@@ -39,37 +48,52 @@ const headerClass = computed(() => {
 </script>
 
 <template>
-  <header
-    v-if="title || backUrl || (onAction && actionTitle)"
+  <component
+    :is="containerTag"
+    v-if="title || backUrl || (onAction && actionTitle) || hasSlots"
     ref="headerElement"
     class="grid h-[64px] shrink-0 grid-cols-[75px_auto_75px] border-b-[0.5px] border-white/10 bg-black px-4"
     data-test-id="appHeader"
   >
     <div class="flex items-center justify-self-start text-base">
-      <CommonBackButton
-        v-if="backUrl"
-        :fallback="backUrl"
-        :label="backTitle"
-        :ignore="backIgnore"
-        :avoid-home-button="backAvoidHomeButton"
-      />
+      <slot
+        name="before"
+        :data="{ backUrl, backTitle, backIgnore, backAvoidHomeButton }"
+      >
+        <CommonBackButton
+          v-if="backUrl"
+          :fallback="backUrl"
+          :label="backTitle"
+          :ignore="backIgnore"
+          :avoid-home-button="backAvoidHomeButton"
+        />
+      </slot>
     </div>
     <div class="flex flex-1 items-center justify-center">
-      <CommonRefetch :refetch="refetch">
-        <h1 :class="headerClass">
-          {{ $t(title) }}
-        </h1>
+      <CommonRefetch v-bind="defaultAttrs" :refetch="refetch">
+        <slot :data="{ defaultAttrs, refetch }">
+          <h1 :class="headerClass">
+            {{ $t(title) }}
+          </h1>
+        </slot>
       </CommonRefetch>
     </div>
-    <div class="flex cursor-pointer items-center justify-self-end text-base">
-      <CommonButton
-        v-if="onAction && actionTitle && !actionHidden"
-        variant="primary"
-        transparent-background
-        @click="onAction?.()"
-      >
-        {{ $t(actionTitle) }}
-      </CommonButton>
+    <div
+      v-if="((onAction || actionTitle) && !actionHidden) || slots.after"
+      class="flex items-center justify-self-end text-base"
+    >
+      <slot name="after" :data="{ actionBtnProps }">
+        <CommonButton
+          v-bind="{
+            variant: 'primary',
+            transparentBackground: true,
+            ...actionBtnProps,
+          }"
+          @click="onAction?.()"
+        >
+          {{ $t(actionTitle) }}
+        </CommonButton>
+      </slot>
     </div>
-  </header>
+  </component>
 </template>

+ 53 - 21
app/frontend/apps/mobile/components/layout/__tests__/LayoutHeader.spec.ts

@@ -2,6 +2,7 @@
 
 import { i18n } from '#shared/i18n.ts'
 import { renderComponent } from '#tests/support/components/index.ts'
+import { expect } from 'vitest'
 import LayoutHeader from '../LayoutHeader.vue'
 
 describe('mobile app header', () => {
@@ -11,32 +12,34 @@ describe('mobile app header', () => {
     expect(view.queryByTestId('appHeader')).not.toBeInTheDocument()
   })
 
-  it('renders title, if specified', async () => {
-    const view = renderComponent(LayoutHeader, {
-      props: { title: 'Test' },
-      router: true,
-    })
-
-    expect(view.getByTestId('appHeader')).toBeInTheDocument()
-    expect(view.getByText('Test')).toBeInTheDocument()
-
-    i18n.setTranslationMap(new Map([['Test2', 'Translated']]))
+  describe('title prop tests', () => {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    let view: ReturnType<typeof renderComponent>
 
-    await view.rerender({ title: 'Test2' })
-
-    expect(view.getByText('Translated')).toBeInTheDocument()
-  })
+    beforeEach(() => {
+      view = renderComponent(LayoutHeader, {
+        props: { title: 'Test' },
+        router: true,
+      })
+    })
+    it('renders translated title, if specified', async () => {
+      expect(view.getByTestId('appHeader')).toBeInTheDocument()
+      expect(view.getByText('Test')).toBeInTheDocument()
 
-  it('can add custom class to title', () => {
-    const view = renderComponent(LayoutHeader, {
-      props: {
+      i18n.setTranslationMap(new Map([['Test2', 'Translated']]))
+      await view.rerender({ title: 'Test2' })
+      expect(view.getByText('Translated')).toBeInTheDocument()
+    })
+    it('should be by default a h1', () => {
+      expect(view.getByRole('heading', { level: 1 })).toBeInTheDocument()
+    })
+    it('can add custom class to title', async () => {
+      await view.rerender({
         title: 'Test',
         titleClass: 'test-class',
-      },
-      router: true,
+      })
+      expect(view.getByText('Test')).toHaveClass('test-class')
     })
-
-    expect(view.getByText('Test')).toHaveClass('test-class')
   })
 
   it('renders back button, if specified', async () => {
@@ -99,4 +102,33 @@ describe('mobile app header', () => {
 
     expect(view.queryByText('Action')).not.toBeInTheDocument()
   })
+
+  describe('slots test', () => {
+    it('display all slots', () => {
+      const view = renderComponent(LayoutHeader, {
+        slots: {
+          before: `<span>Step 1</span>`,
+          default: `<h2>Test Heading</h2>`,
+          after: `Action`,
+        },
+        router: true,
+      })
+      expect(view.getByText('Step 1')).toBeInTheDocument()
+      expect(view.getByRole('heading', { level: 2 })).toBeInTheDocument()
+      expect(view.getByText('Action')).toBeInTheDocument()
+    })
+    it('shows fallback if partial slots are used', () => {
+      const view = renderComponent(LayoutHeader, {
+        title: 'Test',
+        slots: {
+          before: `<span>Step 1</span>`,
+          after: `Action`,
+        },
+        router: true,
+      })
+      expect(view.getByText('Step 1')).toBeInTheDocument()
+      expect(view.getByRole('heading', { level: 1 })).toBeInTheDocument()
+      expect(view.getByText('Action')).toBeInTheDocument()
+    })
+  })
 })

+ 9 - 0
app/frontend/apps/mobile/pages/playground/views/PlaygroundOverview.vue

@@ -12,6 +12,7 @@ import CommonButtonGroup from '#mobile/components/CommonButtonGroup/CommonButton
 import { useUserCreate } from '#mobile/entities/user/composables/useUserCreate.ts'
 import CommonStepper from '#mobile/components/CommonStepper/CommonStepper.vue'
 import { EnumSecurityStateType } from '#shared/components/Form/fields/FieldSecurity/types.ts'
+import LayoutHeader from '#mobile/components/layout/LayoutHeader.vue'
 
 const linkSchemaRaw = [
   {
@@ -400,6 +401,14 @@ const logSubmit = console.log
 
 <template>
   <div class="p-4">
+    <LayoutHeader title="Playground">
+      <template #before>1 / 3</template>
+      <template #after>
+        <CommonButton class="flex-1 px-4 py-2" variant="secondary"
+          >Click
+        </CommonButton>
+      </template>
+    </LayoutHeader>
     <h2 class="text-xl font-bold">Buttons</h2>
     <div class="mt-2 flex gap-3">
       <CommonButton class="flex-1 py-2" variant="primary" />

+ 28 - 29
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewHeader.vue

@@ -1,17 +1,18 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { toRef } from 'vue'
+import LayoutHeader from '#mobile/components/layout/LayoutHeader.vue'
 import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
+
+import { toRef } from 'vue'
 import { useDialog } from '#mobile/composables/useDialog.ts'
-import CommonLoader from '#mobile/components/CommonLoader/CommonLoader.vue'
-import CommonBackButton from '#mobile/components/CommonBackButton/CommonBackButton.vue'
 import { useSessionStore } from '#shared/stores/session.ts'
-import CommonRefetch from '#mobile/components/CommonRefetch/CommonRefetch.vue'
+
 import type {
   TicketById,
   TicketLiveAppUser,
 } from '#shared/entities/ticket/types.ts'
+import CommonLoader from '#mobile/components/CommonLoader/CommonLoader.vue'
 
 interface Props {
   ticket?: TicketById
@@ -51,30 +52,28 @@ const showActions = () => {
 </script>
 
 <template>
-  <header
-    class="grid h-[64px] shrink-0 grid-cols-[75px_auto_75px] border-b-[0.5px] border-white/10 bg-gray-600/90 px-4"
+  <LayoutHeader
+    class="bg-gray-600/90"
+    :refetch="refetchingTicket || loadingTicket"
+    :back-ignore="[`/tickets/${ticket?.internalId}/information`]"
+    back-url="/"
   >
-    <CommonBackButton
-      class="justify-self-start"
-      fallback="/"
-      :ignore="[`/tickets/${ticket?.internalId}/information`]"
-    />
-    <CommonLoader data-test-id="loader-header" :loading="loadingTicket">
-      <div
-        class="flex flex-1 flex-col items-center justify-center text-center text-sm leading-4"
-        data-test-id="header-content"
-      >
-        <CommonRefetch :refetch="refetchingTicket">
-          <div class="font-bold">{{ ticket && `#${ticket.number}` }}</div>
-          <div class="text-gray">
-            {{
-              ticket &&
-              $t('created %s', i18n.relativeDateTime(ticket.createdAt))
-            }}
-          </div>
-        </CommonRefetch>
+    <div
+      class="flex flex-1 flex-col items-center justify-center text-center text-sm leading-4"
+      data-test-id="header-content"
+    >
+      <div class="font-bold">
+        {{ ticket && `#${ticket.number}` }}
+      </div>
+      <div class="text-gray">
+        {{
+          ticket && $t('created %s', i18n.relativeDateTime(ticket.createdAt))
+        }}
       </div>
-      <div class="flex items-center justify-self-end">
+    </div>
+
+    <template #after>
+      <CommonLoader data-test-id="loader-header" :loading="loadingTicket">
         <button
           v-if="liveUserList?.length"
           class="flex ltr:mr-0.5 rtl:ml-0.5"
@@ -106,7 +105,7 @@ const showActions = () => {
         >
           <CommonIcon name="more" size="base" decorative />
         </button>
-      </div>
-    </CommonLoader>
-  </header>
+      </CommonLoader>
+    </template>
+  </LayoutHeader>
 </template>

+ 16 - 26
app/frontend/apps/mobile/pages/ticket/views/TicketCreate.vue

@@ -7,6 +7,7 @@ import { useEventListener } from '@vueuse/core'
 import type { ApolloError } from '@apollo/client'
 
 import Form from '#shared/components/Form/Form.vue'
+import LayoutHeader from '#mobile/components/layout/LayoutHeader.vue'
 import {
   EnumFormUpdaterId,
   EnumObjectManagerObjects,
@@ -36,7 +37,6 @@ import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
 import { populateEditorNewLines } from '#shared/components/Form/fields/FieldEditor/utils.ts'
 import CommonStepper from '#mobile/components/CommonStepper/CommonStepper.vue'
 import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
-import CommonBackButton from '#mobile/components/CommonBackButton/CommonBackButton.vue'
 import { errorOptions } from '#shared/router/error.ts'
 import {
   useTicketDuplicateDetectionHandler,
@@ -563,35 +563,25 @@ export default {
 </script>
 
 <template>
-  <header
+  <LayoutHeader
     ref="headerElement"
+    class="!h-16"
     :style="stickyStyles.header"
-    class="border-b-[0.5px] border-white/10 bg-black px-4"
+    back-url="/"
+    :title="__('Create Ticket')"
   >
-    <div class="grid h-16 grid-cols-[75px_auto_75px]">
-      <div
-        class="flex cursor-pointer items-center justify-self-start text-base"
+    <template #after>
+      <CommonButton
+        variant="submit"
+        form="ticket-create"
+        type="submit"
+        :disabled="submitButtonDisabled"
+        transparent-background
       >
-        <CommonBackButton fallback="/" />
-      </div>
-      <h1
-        class="flex flex-1 items-center justify-center text-center text-lg font-bold"
-      >
-        {{ $t('Create Ticket') }}
-      </h1>
-      <div class="flex items-center justify-self-end text-base">
-        <CommonButton
-          variant="submit"
-          form="ticket-create"
-          type="submit"
-          :disabled="submitButtonDisabled"
-          transparent-background
-        >
-          {{ $t('Create') }}
-        </CommonButton>
-      </div>
-    </div>
-  </header>
+        {{ $t('Create') }}
+      </CommonButton>
+    </template>
+  </LayoutHeader>
   <div
     ref="bodyElement"
     :style="stickyStyles.body"

+ 9 - 21
app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationView.vue

@@ -6,11 +6,10 @@ import type { CommonButtonOption } from '#mobile/components/CommonButtonGroup/ty
 import CommonButtonGroup from '#mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
 import CommonLoader from '#mobile/components/CommonLoader/CommonLoader.vue'
 import { useSessionStore } from '#shared/stores/session.ts'
-import CommonBackButton from '#mobile/components/CommonBackButton/CommonBackButton.vue'
 import { useDialog } from '#mobile/composables/useDialog.ts'
 import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
-import CommonRefetch from '#mobile/components/CommonRefetch/CommonRefetch.vue'
 import { useRoute, useRouter } from 'vue-router'
+import LayoutHeader from '#mobile/components/layout/LayoutHeader.vue'
 import { ticketInformationPlugins } from './plugins/index.ts'
 import { useTicketInformation } from '../../composable/useTicketInformation.ts'
 
@@ -61,26 +60,15 @@ const router = useRouter()
 </script>
 
 <template>
-  <header
+  <LayoutHeader
     ref="headerElement"
-    class="grid h-[64px] shrink-0 grid-cols-[75px_auto_75px] border-b-[0.5px] border-white/10 bg-black px-4"
+    :refetch="refetchingStatus"
+    :back-title="`#${internalId}`"
+    :title="$t('Ticket information')"
+    :back-url="`/tickets/${internalId}`"
     :style="stickyStyles.header"
   >
-    <CommonBackButton
-      class="justify-self-start"
-      :label="`#${internalId}`"
-      :fallback="`/tickets/${internalId}`"
-    />
-    <div class="flex flex-1 items-center justify-center">
-      <CommonRefetch :refetch="refetchingStatus">
-        <h1
-          class="flex items-center justify-center text-center text-lg font-bold"
-        >
-          {{ $t('Ticket information') }}
-        </h1>
-      </CommonRefetch>
-    </div>
-    <div class="flex items-center justify-end">
+    <template #after>
       <button
         v-if="hasPermission('ticket.agent')"
         type="button"
@@ -89,8 +77,8 @@ const router = useRouter()
       >
         <CommonIcon name="more" size="base" decorative />
       </button>
-    </div>
-  </header>
+    </template>
+  </LayoutHeader>
   <div class="flex p-4" :style="stickyStyles.body">
     <h1
       class="line-clamp-3 flex flex-1 items-center break-words text-xl font-bold leading-7"

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