Browse Source

Feature: Desktop-View - Enhance UX by providing contextual help

Benjamin Scharf 9 months ago
parent
commit
40e8bb1105

+ 10 - 3
app/frontend/apps/desktop/components/CommonDialog/CommonDialog.vue

@@ -23,13 +23,19 @@ export interface Props {
   content?: string
   contentPlaceholder?: string[]
   hideFooter?: boolean
+  /**
+   * Inner wrapper for the dialog content.
+   * */
+  wrapperTag?: 'div' | 'article'
   footerActionOptions?: ActionFooterProps
   // 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
 }
 
-const props = withDefaults(defineProps<Props>(), {})
+const props = withDefaults(defineProps<Props>(), {
+  wrapperTag: 'div',
+})
 
 defineOptions({
   inheritAttrs: false,
@@ -83,7 +89,8 @@ onMounted(() => {
     :aria-labelledby="`${dialogId}-title`"
     @click-background="close()"
   >
-    <div
+    <component
+      :is="wrapperTag"
       ref="dialogElement"
       data-common-dialog
       class="flex flex-col gap-3 rounded-xl border border-neutral-100 bg-white p-3 dark:border-gray-900 dark:bg-gray-500"
@@ -122,6 +129,6 @@ onMounted(() => {
           />
         </slot>
       </div>
-    </div>
+    </component>
   </CommonOverlayContainer>
 </template>

+ 1 - 0
app/frontend/apps/desktop/components/CommonDialog/__tests__/CommonDialog.spec.ts

@@ -16,6 +16,7 @@ describe('visuals for common dialog', () => {
     main.id = 'page-main-content'
     document.body.appendChild(main)
   })
+
   beforeEach(() => {
     const { dialogsOptions } = getDialogMeta()
     dialogsOptions.set('dialog', {

+ 19 - 17
app/frontend/apps/desktop/components/CommonLoader/CommonLoader.vue

@@ -20,21 +20,23 @@ export default {
 </script>
 
 <template>
-  <div
-    v-if="loading"
-    v-bind="$attrs"
-    class="flex items-center justify-center"
-    role="status"
-  >
-    <CommonIcon
-      class="fill-yellow-300"
-      name="spinner"
-      animation="spin"
-      :label="__('Loading…')"
-    />
-  </div>
-  <CommonAlert v-else-if="error" v-bind="$attrs" variant="danger">
-    <span v-html="markup($t(error))" />
-  </CommonAlert>
-  <slot v-else />
+  <Transition name="fade" mode="out-in">
+    <div
+      v-if="loading"
+      v-bind="$attrs"
+      class="flex items-center justify-center"
+      role="status"
+    >
+      <CommonIcon
+        class="fill-yellow-300"
+        name="spinner"
+        animation="spin"
+        :label="__('Loading…')"
+      />
+    </div>
+    <CommonAlert v-else-if="error" v-bind="$attrs" variant="danger">
+      <span v-html="markup($t(error))" />
+    </CommonAlert>
+    <slot v-else />
+  </Transition>
 </template>

+ 25 - 0
app/frontend/apps/desktop/components/CommonPageHelp/CommonHelpText.vue

@@ -0,0 +1,25 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
+
+interface Props {
+  helpText?: string | string[]
+}
+
+defineProps<Props>()
+</script>
+
+<template>
+  <div class="ltr:text-left rtl:text-right">
+    <template v-if="Array.isArray(helpText)">
+      <CommonLabel
+        v-for="(text, index) in helpText"
+        :key="`${text}-${index}`"
+        tag="p"
+        >{{ text }}</CommonLabel
+      >
+    </template>
+    <CommonLabel v-else tag="p">{{ helpText }}</CommonLabel>
+  </div>
+</template>

+ 5 - 2
app/frontend/apps/desktop/components/CommonPageHelp/CommonPageHelpDialog.vue

@@ -1,21 +1,24 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import CommonDialog from '#desktop/components/CommonDialog/CommonDialog.vue'
+import { ref, type Slot } from 'vue'
 
-import type { Slot } from 'vue'
+import CommonDialog from '#desktop/components/CommonDialog/CommonDialog.vue'
 
 interface Props {
   content: Slot
 }
 
 defineProps<Props>()
+
+ref<InstanceType<typeof CommonDialog>>()
 </script>
 
 <template>
   <CommonDialog
     name="page-help"
     :header-title="__('Help')"
+    wrapper-tag="article"
     header-icon="question-circle"
     :footer-action-options="{
       hideCancelButton: true,

+ 26 - 0
app/frontend/apps/desktop/components/CommonPageHelp/__tests__/CommonHelpText.spec.ts

@@ -0,0 +1,26 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '#tests/support/components/index.ts'
+
+import CommonHelpText from '#desktop/components/CommonPageHelp/CommonHelpText.vue'
+
+describe('CommonHelpText', () => {
+  it('supports single Paragraph', () => {
+    const wrapper = renderComponent(CommonHelpText, {
+      props: {
+        helpText: 'Hello Test World!',
+      },
+    })
+    expect(wrapper.getByText('Hello Test World!')).toBeInTheDocument()
+  })
+  it('supports multiple Paragraph', () => {
+    const wrapper = renderComponent(CommonHelpText, {
+      props: {
+        helpText: ['Hello Test World!', 'Hello Foo World!'],
+      },
+    })
+
+    expect(wrapper.getByText('Hello Test World!')).toBeInTheDocument()
+    expect(wrapper.getByText('Hello Foo World!')).toBeInTheDocument()
+  })
+})

+ 27 - 9
app/frontend/apps/desktop/components/CommonPageHelp/__tests__/CommonPageHelp.spec.ts

@@ -1,32 +1,50 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
+import { beforeAll } from 'vitest'
+
 import { renderComponent } from '#tests/support/components/index.ts'
 
 import CommonPageHelp from '../CommonPageHelp.vue'
 
-const renderPageHelp = (
-  props: Record<string, unknown> = {},
-  options: any = {},
-) => {
+const renderPageHelp = (options: any = {}) => {
   return renderComponent(CommonPageHelp, {
-    props,
     ...options,
     dialog: true,
   })
 }
 
 describe('CommonPageHelp.vue', () => {
+  beforeAll(() => {
+    const main = document.createElement('main')
+    main.id = 'page-main-content'
+    document.body.appendChild(main)
+  })
+
+  afterAll(() => {
+    document.body.innerHTML = ''
+  })
+
   it('show help button', async () => {
     const view = renderPageHelp({
       slots: {
-        default: 'A help example.',
+        default: () => 'A help example.',
       },
     })
 
     expect(view.getByRole('button', { name: 'Help' })).toBeInTheDocument()
-
-    // TODO ...
+    expect(view.getByIconName('question-circle')).toBeInTheDocument()
   })
 
-  // TODO ...
+  it('opens help dialog', async () => {
+    const view = renderPageHelp({
+      slots: {
+        default: () => 'A help example.',
+      },
+    })
+
+    await view.events.click(view.getByRole('button', { name: 'Help' }))
+
+    expect(await view.findByRole('dialog')).toBeInTheDocument()
+    expect(await view.findByText('A help example.')).toBeInTheDocument()
+  })
 })

+ 5 - 0
app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt

@@ -9,6 +9,7 @@
       <common-label-stub
         class="font-normal text-stone-200 dark:text-neutral-500 text-left"
         size="small"
+        tag="span"
       />
       
       
@@ -19,6 +20,7 @@
       <common-label-stub
         class="font-normal text-stone-200 dark:text-neutral-500 text-left"
         size="small"
+        tag="span"
       />
       
       
@@ -30,6 +32,7 @@
       <common-label-stub
         class="font-normal text-stone-200 dark:text-neutral-500"
         size="small"
+        tag="span"
       />
     </th>
   </thead>
@@ -44,6 +47,7 @@
         <common-label-stub
           class="inline text-black dark:text-white"
           size="medium"
+          tag="span"
         />
         
         
@@ -56,6 +60,7 @@
         <common-label-stub
           class="inline text-black dark:text-white"
           size="medium"
+          tag="span"
         />
         
         

+ 39 - 5
app/frontend/apps/desktop/components/layout/LayoutContent.vue

@@ -5,34 +5,68 @@ import { computed } from 'vue'
 
 import CommonBreadcrumb from '#desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue'
 import type { BreadcrumbItem } from '#desktop/components/CommonBreadcrumb/types.ts'
+import CommonHelpText from '#desktop/components/CommonPageHelp/CommonHelpText.vue'
+import CommonPageHelp from '#desktop/components/CommonPageHelp/CommonPageHelp.vue'
 import LayoutBottomBar from '#desktop/components/layout/LayoutBottomBar.vue'
 import LayoutMain from '#desktop/components/layout/LayoutMain.vue'
+import { useTransitionConfig } from '#desktop/composables/useTransitionConfig.ts'
 
 import type { ContentWidth } from './types'
 
-interface Props {
-  width?: ContentWidth
+export interface Props {
   breadcrumbItems: BreadcrumbItem[]
+  width?: ContentWidth
+  helpText?: string[] | string
+  /**
+   * Hides `default slot` content and shows help text if provided
+   */
+  showInlineHelp?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
   width: 'full',
+  showInlineHelp: false,
 })
 
 const maxWidth = computed(() =>
   props.width === 'narrow' ? '600px' : undefined,
 )
+
+const { durations } = useTransitionConfig()
 </script>
 
 <template>
   <div class="flex max-h-screen flex-col">
     <LayoutMain>
-      <div class="flex grow flex-col gap-3" :style="{ maxWidth }">
+      <div
+        data-test-id="layout-wrapper"
+        class="flex grow flex-col gap-3"
+        :style="{ maxWidth }"
+      >
         <div class="flex items-center justify-between">
           <CommonBreadcrumb :items="breadcrumbItems" />
-          <slot name="headerRight" />
+          <div
+            v-if="$slots.headerRight || helpText || $slots.helpPage"
+            class="flex gap-4 ltr:text-left rtl:text-right"
+          >
+            <CommonPageHelp
+              v-if="!showInlineHelp && (helpText || $slots.helpPage)"
+            >
+              <slot name="helpPage">
+                <CommonHelpText :help-text="helpText" />
+              </slot>
+            </CommonPageHelp>
+
+            <slot name="headerRight" />
+          </div>
         </div>
-        <slot />
+
+        <Transition :duration="durations.normal" name="fade" mode="out-in">
+          <slot v-if="!showInlineHelp" />
+          <slot v-else name="helpPage">
+            <CommonHelpText :help-text="helpText" />
+          </slot>
+        </Transition>
       </div>
     </LayoutMain>
 

+ 143 - 0
app/frontend/apps/desktop/components/layout/__tests__/LayoutContent.spec.ts

@@ -0,0 +1,143 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+import { beforeAll } from 'vitest'
+import { h } from 'vue'
+
+import { renderComponent } from '#tests/support/components/index.ts'
+
+import LayoutContent, {
+  type Props,
+} from '#desktop/components/layout/LayoutContent.vue'
+
+const breadcrumbItems = [
+  {
+    label: 'Test Profile',
+    route: '/test-profile',
+  },
+  {
+    label: 'Test Foo',
+    route: '/test-foo',
+  },
+]
+
+const renderLayoutContent = (
+  slots: typeof LayoutContent.slots,
+  props?: Props,
+) => {
+  return renderComponent(LayoutContent, {
+    props: {
+      ...props,
+      breadcrumbItems: props?.breadcrumbItems || breadcrumbItems,
+    },
+    slots,
+    dialog: true,
+    router: true,
+  })
+}
+
+describe('LayoutContent', () => {
+  beforeAll(() => {
+    const main = document.createElement('main')
+    main.id = 'page-main-content'
+    document.body.appendChild(main)
+  })
+
+  afterAll(() => {
+    document.body.innerHTML = ''
+  })
+
+  it('renders component with default slot content', () => {
+    const wrapper = renderLayoutContent({ default: () => 'Hello Test World!' })
+
+    expect(wrapper.getByText('Hello Test World!')).toBeInTheDocument()
+    expect(wrapper.getByText('Test Profile')).toBeInTheDocument()
+    expect(wrapper.getByText('Test Foo')).toBeInTheDocument()
+  })
+
+  it('renders namespaced content', () => {
+    const wrapper = renderLayoutContent({
+      default: () => 'Hello Test World!',
+      headerRight: () => 'Hello Content Right',
+      helpPage: () => 'Hello Help Text',
+    })
+
+    expect(wrapper.getByText('Hello Test World!')).toBeInTheDocument()
+    expect(wrapper.getByText('Hello Content Right')).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Hello Help Text')).not.toBeInTheDocument() // should not show help text in main content area
+  })
+
+  it('shows helpText and hide helpText button if hideDefault prop is true', () => {
+    const wrapper = renderLayoutContent(
+      {
+        default: () => 'Hello Test World!',
+        helpPage: () => 'Hello Help Text',
+      },
+      { showInlineHelp: true, breadcrumbItems },
+    )
+
+    expect(wrapper.queryByText('Hello Test World!')).not.toBeInTheDocument() // should not show default content
+    expect(
+      wrapper.queryByRole('button', { name: 'Help' }),
+    ).not.toBeInTheDocument()
+
+    expect(wrapper.getByText('Hello Help Text')).toBeInTheDocument()
+  })
+
+  it('shows help dialog with multiple paragraphs', async () => {
+    const wrapper = renderLayoutContent(
+      {
+        default: () => 'Hello Test World!',
+      },
+      { breadcrumbItems, helpText: ['Hello Test World!', 'Hello Test Foo!'] },
+    )
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Help' }))
+
+    expect(await wrapper.findByText('Hello Test World!')).toBeInTheDocument()
+    expect(await wrapper.findByText('Hello Test Foo!')).toBeInTheDocument()
+  })
+
+  it('shows help dialog with single paragraph', async () => {
+    const wrapper = renderLayoutContent(
+      {
+        default: () => 'Hello Default Slot Content!',
+      },
+      { breadcrumbItems, helpText: 'Hello Test Text World!' },
+    )
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Help' }))
+
+    expect(
+      await wrapper.findByText('Hello Test Text World!'),
+    ).toBeInTheDocument()
+  })
+
+  it('shows custom help text component', async () => {
+    const wrapper = renderLayoutContent(
+      {
+        default: () => 'Hello Test World!',
+        helpPage: () => h('h1', 'Hello custom Help Text'),
+      },
+      { breadcrumbItems },
+    )
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Help' }))
+
+    expect(
+      await wrapper.findByText('Hello custom Help Text'),
+    ).toBeInTheDocument()
+  })
+
+  it('allows custom widths', async () => {
+    const wrapper = renderLayoutContent(
+      {
+        default: () => 'Hello Test World!',
+      },
+      { breadcrumbItems, width: 'narrow' },
+    )
+
+    expect(wrapper.getByTestId('layout-wrapper')).toHaveStyle({
+      maxWidth: '600px',
+    })
+  })
+})

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