Browse Source

Feature: Mobile - Add Ticket Overview page

Vladimir Sheremet 2 years ago
parent
commit
a052263dc4

+ 5 - 0
.eslintrc.js

@@ -49,6 +49,7 @@ module.exports = {
           'vite.config.ts',
           'app/frontend/build/**',
           'app/frontend/**/*.spec.*',
+          'app/frontend/**/__tests__/**/*',
           'app/frontend/tests/**/*',
           'app/frontend/**/*.stories.ts',
           '.storybook/**/*',
@@ -161,6 +162,10 @@ module.exports = {
           ['@tests', path.resolve(__dirname, './app/frontend/tests')],
           ['@stories', path.resolve(__dirname, './app/frontend/stories')],
           ['@cy', path.resolve(__dirname, './.cypress')],
+          [
+            'vitest',
+            path.resolve(__dirname, 'node_modules/vitest/dist/index.mjs'),
+          ],
         ],
         extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
       },

+ 8 - 2
app/frontend/apps/mobile/components/CommonSectionMenu/CommonSectionMenu.vue

@@ -1,6 +1,7 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import type { RouteLocationRaw } from 'vue-router'
 import type { MenuItem } from './types'
 import CommonSectionMenuLink from './CommonSectionMenuLink.vue'
 
@@ -8,6 +9,7 @@ import CommonSectionMenuLink from './CommonSectionMenuLink.vue'
 
 export interface Props {
   actionTitle?: string
+  actionLink?: RouteLocationRaw
   headerTitle?: string
   items?: MenuItem[]
 }
@@ -28,17 +30,21 @@ const clickOnAction = (event: MouseEvent) => {
     <div class="text-white/80 ltr:pl-4 rtl:pr-4">
       <slot name="header">{{ i18n.t(headerTitle) }}</slot>
     </div>
-    <div
+    <component
+      :is="actionLink ? 'CommonLink' : 'div'"
       v-if="actionTitle"
+      :link="actionLink"
       class="cursor-pointer text-blue ltr:pr-4 rtl:pl-4"
       @click="clickOnAction"
     >
       {{ i18n.t(actionTitle) }}
-    </div>
+    </component>
   </div>
   <div
     class="w-fill mb-6 flex flex-col rounded-xl bg-gray-500 px-3 py-1 text-base text-white"
+    v-bind="$attrs"
   >
+    <slot name="before-items" />
     <slot>
       <template v-for="(item, idx) in items" :key="idx">
         <CommonSectionMenuLink v-if="item.type === 'link'" v-bind="item" />

+ 2 - 2
app/frontend/apps/mobile/components/CommonSectionMenu/CommonSectionMenuLink.vue

@@ -11,7 +11,7 @@ export interface Props {
   icon?: string | (IconProps & HTMLAttributes)
   iconBg?: string
   // TODO maybe change the name based on the usage
-  information?: string
+  information?: string | number
 }
 
 const props = defineProps<Props>()
@@ -55,7 +55,7 @@ const iconProps = computed<IconProps | null>(() => {
       </div>
 
       <div class="mr-1 flex items-center">
-        <slot name="right">{{ i18n.t(information) }}</slot>
+        <slot name="right">{{ information && i18n.t(`${information}`) }}</slot>
         <CommonIcon
           class="text-gray-300 ltr:ml-2 rtl:mr-2"
           :name="`arrow-${locale.localeData?.dir === 'rtl' ? 'left' : 'right'}`"

+ 1 - 1
app/frontend/apps/mobile/composables/useHeader.ts

@@ -9,7 +9,7 @@ export interface HeaderOptions {
   backTitle?: string | ComputedRef<string>
   backUrl?: RouteLocationRaw | ComputedRef<RouteLocationRaw>
   actionTitle?: string | ComputedRef<string>
-  onAction?(): void
+  onAction?(): unknown
 }
 
 export const headerOptions = ref<HeaderOptions>({})

+ 1 - 1
app/frontend/apps/mobile/modules/account/__tests__/editing-avatar.spec.ts

@@ -43,7 +43,7 @@ describe('editing avatar', () => {
   })
 
   afterEach(() => {
-    vi.mocked(console.log).mockRestore()
+    vi.spyOn(console, 'log').mockRestore()
   })
 
   it('can remove avatar', async () => {

+ 46 - 0
app/frontend/apps/mobile/modules/home/__tests__/mocks.ts

@@ -0,0 +1,46 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { OverviewsQuery } from '@shared/graphql/types'
+import { mock } from 'vitest-mock-extended'
+
+export const getApiTicketOverviews = (): OverviewsQuery => ({
+  overviews: mock<OverviewsQuery['overviews']>(
+    {
+      pageInfo: {
+        endCursor: null,
+        hasNextPage: false,
+      },
+      edges: [
+        {
+          cursor: 'node1',
+          node: {
+            id: '1',
+            name: __('Overview 1'),
+            ticketCount: 1,
+          },
+        },
+        {
+          cursor: 'node2',
+          node: {
+            id: '2',
+            name: __('Overview 2'),
+            ticketCount: 2,
+          },
+        },
+        {
+          cursor: 'node3',
+          node: {
+            id: '3',
+            name: __('Overview 3'),
+            ticketCount: 3,
+          },
+        },
+        {
+          cursor: 'node4',
+          node: null,
+        },
+      ],
+    },
+    { deep: true },
+  ),
+})

+ 149 - 0
app/frontend/apps/mobile/modules/home/__tests__/moving-ticket-overview.spec.ts

@@ -0,0 +1,149 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { OverviewsDocument } from '@shared/entities/ticket/graphql/queries/overviews.api'
+import { getAllByTestId, getByTestId, within } from '@testing-library/vue'
+import createMockClient from '@tests/support/mock-apollo-client'
+import { visitView } from '@tests/support/components/visitView'
+import {
+  NotificationTypes,
+  useNotifications,
+} from '@shared/components/CommonNotifications'
+import { mockAccount } from '@tests/support/mock-account'
+import { getIconByName } from '@tests/support/components/iconQueries'
+import { getTicketOverviewStorage } from '../helpers/ticketOverviewStorage'
+import { getApiTicketOverviews } from './mocks'
+
+const actualLocalStorage = window.localStorage
+
+describe('playing with overviews', () => {
+  beforeEach(() => {
+    mockAccount({ id: '666' })
+    createMockClient(
+      [
+        {
+          operationDocument: OverviewsDocument,
+          handler: async () => ({ data: getApiTicketOverviews() }),
+        },
+      ],
+      true,
+    )
+  })
+
+  afterEach(() => {
+    window.localStorage = actualLocalStorage
+    const { saveOverviews } = getTicketOverviewStorage()
+    saveOverviews([])
+  })
+
+  test('loading overviews from local storage', async () => {
+    const { saveOverviews, LOCAL_STORAGE_NAME } = getTicketOverviewStorage()
+    saveOverviews(['1', '2'])
+
+    const view = await visitView('/favorite/ticker-overviews/edit')
+
+    expect(view.getIconByName('loader')).toBeInTheDocument()
+
+    const includedOverviewsUtils = within(
+      await view.findByTestId('includedOverviews'),
+    )
+
+    const includedOverviews = await includedOverviewsUtils.findAllByTestId(
+      'overviewItem',
+    )
+
+    expect(includedOverviews).toHaveLength(2)
+    expect(includedOverviews[0]).toHaveTextContent('Overview 1')
+    expect(includedOverviews[1]).toHaveTextContent('Overview 2')
+
+    const excludedOverviewsUtils = within(
+      await view.findByTestId('excludedOverviews'),
+    )
+
+    const excludedOverviews =
+      excludedOverviewsUtils.getAllByTestId('overviewItem')
+
+    expect(excludedOverviews).toHaveLength(1)
+    expect(excludedOverviews[0]).toHaveTextContent('Overview 3')
+
+    vi.stubGlobal('localStorage', {
+      setItem: vi.fn(),
+    })
+
+    await view.events.click(view.getByText('Done'))
+
+    expect(localStorage.setItem).toHaveBeenCalledWith(
+      LOCAL_STORAGE_NAME,
+      '["1","2"]',
+    )
+  })
+
+  test('removing/adding overviews', async () => {
+    const { LOCAL_STORAGE_NAME } = getTicketOverviewStorage()
+
+    const view = await visitView('/favorite/ticker-overviews/edit')
+
+    const buttonsRemove = await view.findAllIconsByName('minus-small')
+
+    expect(buttonsRemove).toHaveLength(3)
+
+    const [overviewOneButton] = buttonsRemove
+
+    await view.events.click(overviewOneButton)
+
+    expect(view.getAllIconsByName('minus-small')).toHaveLength(2)
+
+    const overviewOneInExcluded = getByTestId(
+      view.getByTestId('excludedOverviews'),
+      'overviewItem',
+    )
+
+    expect(overviewOneInExcluded).toHaveTextContent('Overview 1')
+
+    const buttonAdd = getIconByName(overviewOneInExcluded, 'plus-small')
+
+    await view.events.click(buttonAdd)
+
+    const includedOverviews = getAllByTestId(
+      view.getByTestId('includedOverviews'),
+      'overviewItem',
+    )
+
+    expect(includedOverviews.at(-1)).toHaveTextContent('Overview 1')
+
+    vi.stubGlobal('localStorage', {
+      setItem: vi.fn(),
+    })
+
+    await view.events.click(view.getByText('Done'))
+
+    expect(localStorage.setItem).toHaveBeenCalledWith(
+      LOCAL_STORAGE_NAME,
+      '["2","3","1"]',
+    )
+
+    const { notify } = useNotifications()
+
+    expect(notify).toHaveBeenCalledWith({
+      type: NotificationTypes.Success,
+      message: 'Ticket Overview settings are saved',
+    })
+  })
+
+  test('gives error, when trying to save no overviews', async () => {
+    const { saveOverviews } = getTicketOverviewStorage()
+    saveOverviews(['1'])
+    const view = await visitView('/favorite/ticker-overviews/edit')
+
+    const buttonRemove = await view.findIconByName('minus-small')
+
+    await view.events.click(buttonRemove)
+    await view.events.click(view.getByText('Done'))
+
+    const { notify } = useNotifications()
+
+    expect(notify).toHaveBeenCalledWith({
+      type: NotificationTypes.Error,
+      message: 'Please select at least one ticket overview',
+    })
+  })
+})

+ 51 - 0
app/frontend/apps/mobile/modules/home/__tests__/opening-home.spec.ts

@@ -0,0 +1,51 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { OverviewsDocument } from '@shared/entities/ticket/graphql/queries/overviews.api'
+import { visitView } from '@tests/support/components/visitView'
+import { mockAccount } from '@tests/support/mock-account'
+import createMockClient from '@tests/support/mock-apollo-client'
+import { mockPermissions } from '@tests/support/mock-permissions'
+import { flushPromises } from '@vue/test-utils'
+import { getTicketOverviewStorage } from '../helpers/ticketOverviewStorage'
+import { getApiTicketOverviews } from './mocks'
+
+describe('home page', () => {
+  beforeEach(() => {
+    mockAccount({ id: '666' })
+    createMockClient([
+      {
+        operationDocument: OverviewsDocument,
+        handler: async () => ({ data: getApiTicketOverviews() }),
+      },
+    ])
+  })
+
+  test('renders ticket overviews based on localStorage', async () => {
+    mockPermissions(['ticket.agent', 'ticket.customer'])
+    const { saveOverviews } = getTicketOverviewStorage()
+    saveOverviews(['3', '2'])
+
+    const view = await visitView('/')
+
+    expect(view.getIconByName('loader')).toBeInTheDocument()
+    expect(view.getByRole('link', { name: /Edit/ })).toHaveAttribute(
+      'href',
+      '/favorite/ticker-overviews/edit',
+    )
+
+    const overviews = await view.findAllByText(/^Overview/)
+
+    expect(overviews).toHaveLength(2)
+    expect(overviews[0]).toHaveTextContent('Overview 3')
+    expect(overviews[1]).toHaveTextContent('Overview 2')
+
+    mockPermissions([])
+
+    await flushPromises()
+
+    expect(
+      view.queryByRole('link', { name: /Edit/ }),
+      "doesn't have link when account doesn't have rights",
+    ).not.toBeInTheDocument()
+  })
+})

+ 54 - 0
app/frontend/apps/mobile/modules/home/components/TicketOverviewEditItem.vue

@@ -0,0 +1,54 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { TicketOverview } from '../stores/ticketOverviews'
+
+const props = defineProps<{
+  action: 'add' | 'delete'
+  draggable?: boolean
+  overview: TicketOverview
+}>()
+
+const emit = defineEmits<{
+  (e: 'action'): void
+}>()
+
+const icon = computed(() => {
+  if (props.action === 'add') {
+    return {
+      name: 'plus-small',
+      class: 'bg-green',
+    }
+  }
+
+  return {
+    name: 'minus-small',
+    class: 'bg-red',
+  }
+})
+</script>
+
+<template>
+  <div
+    class="flex min-h-[54px] items-center border-b border-gray-300 last:border-0"
+    data-test-id="overviewItem"
+  >
+    <div
+      class="flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-gray-500 ltr:mr-2 rtl:ml-2"
+      :class="icon.class"
+      @click="emit('action')"
+    >
+      <CommonIcon :name="icon.name" size="tiny" />
+    </div>
+    <div class="flex-1">
+      {{ $t(overview.name) }}
+    </div>
+    <CommonIcon
+      v-if="draggable"
+      name="draggable"
+      size="small"
+      class="handler cursor-move text-white/30 ltr:mr-4 rtl:ml-4"
+    />
+  </div>
+</template>

+ 23 - 0
app/frontend/apps/mobile/modules/home/helpers/ticketOverviewStorage.ts

@@ -0,0 +1,23 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import useSession from '@shared/stores/session'
+
+export const getTicketOverviewStorage = () => {
+  const session = useSession()
+
+  const LOCAL_STORAGE_NAME = `ticket-overviews-${session.user?.id || '1'}`
+
+  const getOverviews = (): string[] => {
+    return JSON.parse(localStorage.getItem(LOCAL_STORAGE_NAME) || '[]')
+  }
+
+  const saveOverviews = (overviews: string[]) => {
+    return localStorage.setItem(LOCAL_STORAGE_NAME, JSON.stringify(overviews))
+  }
+
+  return {
+    getOverviews,
+    saveOverviews,
+    LOCAL_STORAGE_NAME,
+  }
+}

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