Browse Source

Feature: Mobile - Add Tickets List interface

Vladimir Sheremet 2 years ago
parent
commit
f9a6c17645

+ 20 - 0
app/frontend/apps/mobile/components/CommonLoader/CommonLoader.vue

@@ -0,0 +1,20 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+defineProps<{
+  loading: boolean
+}>()
+</script>
+
+<script lang="ts">
+export default {
+  inheritAttrs: false,
+}
+</script>
+
+<template>
+  <div v-if="loading" v-bind="$attrs" class="items-enter flex justify-center">
+    <CommonIcon name="loader" animation="spin" />
+  </div>
+  <slot v-else />
+</template>

+ 5 - 2
app/frontend/apps/mobile/components/CommonSelect/CommonSelect.vue

@@ -14,6 +14,8 @@ export interface Props {
    */
   passive?: boolean
   multiple?: boolean
+  noClose?: boolean
+  noOptionsLabelTranslation?: boolean
 }
 
 const props = defineProps<Props>()
@@ -79,7 +81,7 @@ const select = (option: SelectOption) => {
     localValue.value = option.value
   }
 
-  if (!props.multiple) {
+  if (!props.multiple && !props.noClose) {
     closeDialog()
   }
 }
@@ -132,7 +134,7 @@ const advanceDialogFocus = (event: KeyboardEvent, currentIndex: number) => {
           <div
             ref="dialogElement"
             role="listbox"
-            class="w-full divide-y divide-solid divide-white/10"
+            class="max-h-[50vh] w-full divide-y divide-solid divide-white/10 overflow-y-auto"
           >
             <CommonSelectItem
               v-for="(option, index) in options"
@@ -140,6 +142,7 @@ const advanceDialogFocus = (event: KeyboardEvent, currentIndex: number) => {
               :selected="isCurrentValue(option.value)"
               :multiple="multiple"
               :option="option"
+              :no-label-translate="noOptionsLabelTranslation"
               @select="select($event)"
               @keydown="advanceDialogFocus($event, index)"
             />

+ 14 - 2
app/frontend/apps/mobile/components/CommonSelect/CommonSelectItem.vue

@@ -3,11 +3,14 @@
 <script setup lang="ts">
 import { SelectOption } from '@shared/components/Form/fields/FieldSelect/types'
 import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
+import { computed } from 'vue'
+import { i18n } from '@shared/i18n'
 
-defineProps<{
+const props = defineProps<{
   option: SelectOption
   selected: boolean
   multiple?: boolean
+  noLabelTranslate?: boolean
 }>()
 
 const emit = defineEmits<{
@@ -17,6 +20,15 @@ const emit = defineEmits<{
 const select = (option: SelectOption) => {
   emit('select', option)
 }
+
+const label = computed(() => {
+  const { option } = props
+  if (props.noLabelTranslate) {
+    return option.label
+  }
+
+  return i18n.t(option.label, ...(option.labelPlaceholder || []))
+})
 </script>
 
 <template>
@@ -67,7 +79,7 @@ const select = (option: SelectOption) => {
       }"
       class="grow text-white/80"
     >
-      {{ option.label || option.value }}
+      {{ label || option.value }}
     </span>
     <CommonIcon
       v-if="!multiple && selected"

+ 16 - 1
app/frontend/apps/mobile/components/CommonSelect/__tests__/CommonSelect.spec.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
+import { i18n } from '@shared/i18n'
 import { renderComponent } from '@tests/support/components'
 import { ref, Ref } from 'vue'
 import CommonSelect, { type Props } from '../CommonSelect.vue'
@@ -21,7 +22,7 @@ const options = [
 
 const html = String.raw
 
-const renderSelect = (props: Props, modelValue: Ref) => {
+const renderSelect = (props: Props, modelValue?: Ref) => {
   return renderComponent(CommonSelect, {
     props,
     slots: {
@@ -105,5 +106,19 @@ describe('interacting with CommonSelect', () => {
     expect(view.emitted().select).toBeUndefined()
     expect(modelValue.value).toBeUndefined()
   })
+  test('translated values', async () => {
+    i18n.setTranslationMap(new Map([[options[0].label, 'Translated Item A']]))
+    const view = renderSelect({ options })
+
+    await view.events.click(view.getByText('Open Select'))
+    expect(view.getByText('Translated Item A')).toBeInTheDocument()
+  })
+  test("doesn't translate with no-translate prop", async () => {
+    i18n.setTranslationMap(new Map([[options[0].label, 'Translated Item A']]))
+    const view = renderSelect({ options, noOptionsLabelTranslation: true })
+
+    await view.events.click(view.getByText('Open Select'))
+    expect(view.getByText(/^Item A$/)).toBeInTheDocument()
+  })
   // TODO e2e test on keyboard interaction (select with space, moving up/down)
 })

+ 2 - 1
app/frontend/apps/mobile/components/Ticket/TicketItem.stories.ts

@@ -14,7 +14,7 @@ const Template = createTemplate<Props>(TicketItem)
 const ticket = {
   id: '54321',
   number: '12345',
-  state: TicketState.Open,
+  state: { name: TicketState.Open },
   title: 'Test Ticket',
   owner: {
     firstname: 'John',
@@ -33,6 +33,7 @@ export const Default = Template.create({
     },
     priority: {
       name: 'HIGH',
+      defaultCreate: false,
     },
   },
 })

+ 32 - 20
app/frontend/apps/mobile/components/Ticket/TicketItem.vue

@@ -19,42 +19,51 @@ const props = defineProps<Props>()
 
 const { stringUpdated } = useEditedBy(toRef(props, 'entity'))
 
-// TODO
 const priority = computed<Priority | null>(() => {
   const { entity } = props
-  if (!entity.priority) {
+  if (!entity.priority || entity.priority.defaultCreate) {
     return null
   }
   return {
-    class: entity.priority.uiColor
-      ? `u-${entity.priority.uiColor}-color`
-      : `u-default-color`,
+    class: `u-${entity.priority.uiColor || 'default'}-color`,
     text: entity.priority.name.toUpperCase(),
   }
 })
+
+const customer = computed(() => {
+  const { customer } = props.entity
+  if (!customer) return ''
+  const { fullname } = customer
+  if (fullname === '-') return ''
+  return fullname
+})
 </script>
 
 <template>
-  <div class="flex">
-    <div class="flex w-12 items-center justify-center">
+  <CommonLink
+    :link="`/#ticket/zoom/${entity.number}`"
+    class="flex cursor-pointer ltr:pr-3 rtl:pl-3"
+  >
+    <div class="flex w-14 items-center justify-center">
       <!-- TODO label? -->
-      <CommonTicketStateIndicator
-        :status="entity.state"
-        :label="entity.state"
-      />
+      <CommonTicketStateIndicator :status="entity.state.name" label="" />
     </div>
     <div
-      class="flex flex-1 items-center border-b border-white/10 py-3 text-gray-100"
+      class="flex flex-1 items-center gap-1 border-b border-white/10 py-3 text-gray-100 ltr:pr-2 rtl:pl-2"
     >
       <div class="flex-1">
         <div class="flex">
-          <div>#{{ entity.id }}</div>
-          <div v-if="entity.owner" class="px-1">·</div>
-          <div v-if="entity.owner">
-            {{ entity.owner.firstname }} {{ entity.owner.lastname }}
-          </div>
+          <div>#{{ entity.number }}</div>
+          <template v-if="customer">
+            <div class="px-1">·</div>
+            <div
+              class="max-w-[50vw] overflow-hidden text-ellipsis whitespace-nowrap"
+            >
+              {{ customer }}
+            </div>
+          </template>
         </div>
-        <div class="mb-1 text-lg font-bold">
+        <div class="mb-1 text-lg font-bold leading-5 line-clamp-3">
           <slot>
             {{ entity.title }}
           </slot>
@@ -69,12 +78,15 @@ const priority = computed<Priority | null>(() => {
       </div>
       <div
         v-if="priority"
-        :class="[priority.class, 'h-min rounded-[4px] py-1 px-2']"
+        :class="[
+          priority.class,
+          'h-min whitespace-nowrap rounded-[4px] py-1 px-2',
+        ]"
       >
         {{ priority.text }}
       </div>
     </div>
-  </div>
+  </CommonLink>
 </template>
 
 <style scoped lang="scss">

+ 11 - 6
app/frontend/apps/mobile/components/Ticket/__tests__/TicketItem.spec.ts

@@ -17,11 +17,12 @@ describe('ticket item display', () => {
     const ticket: TicketItemData = {
       id: '54321',
       number: '12345',
-      state: TicketState.Open,
+      state: { name: TicketState.Open },
       title: 'test ticket',
-      owner: {
+      customer: {
         firstname: 'John',
         lastname: 'Doe',
+        fullname: 'John Doe',
       },
       updatedAt: new Date(2022, 1, 1, 10, 0, 0, 0).toISOString(),
       updatedBy: {
@@ -32,6 +33,7 @@ describe('ticket item display', () => {
       priority: {
         name: 'high',
         uiColor: 'high-priority',
+        defaultCreate: false,
       },
     }
 
@@ -40,10 +42,12 @@ describe('ticket item display', () => {
         entity: ticket,
       },
       store: true,
+      router: true,
     })
 
-    expect(view.getByAltText(TicketState.Open)).toBeInTheDocument()
-    expect(view.getByText('#54321')).toBeInTheDocument()
+    // TODO alt removed from img, maybe return? remove if error
+    // expect(view.getByAltText(TicketState.Open)).toBeInTheDocument()
+    expect(view.getByText('#12345')).toBeInTheDocument()
     expect(view.getByText('·')).toBeInTheDocument()
     expect(view.getByText('John Doe')).toBeInTheDocument()
     expect(view.getByText('test ticket')).toBeInTheDocument()
@@ -62,7 +66,7 @@ describe('ticket item display', () => {
     const ticket: TicketItemData = {
       id: '54321',
       number: '12345',
-      state: TicketState.Open,
+      state: { name: TicketState.Open },
       title: 'test ticket',
     }
 
@@ -71,9 +75,10 @@ describe('ticket item display', () => {
         entity: ticket,
       },
       store: true,
+      router: true,
     })
 
-    expect(view.getByText('#54321')).toBeInTheDocument()
+    expect(view.getByText('#12345')).toBeInTheDocument()
     expect(view.queryByText('·')).not.toBeInTheDocument()
     expect(view.getByText('test ticket')).toBeInTheDocument()
 

+ 9 - 2
app/frontend/apps/mobile/components/Ticket/types.ts

@@ -7,16 +7,23 @@ export interface TicketItemData {
   id: string
   title: string
   number: string
-  state: TicketState
-  // TODO 2022-06-01 Sheremet V.A.
+  state: {
+    name: TicketState | string
+  }
   priority?: {
     name: string
+    defaultCreate: boolean
     uiColor?: Maybe<string>
   }
   owner?: {
     firstname?: Maybe<string>
     lastname?: Maybe<string>
   }
+  customer?: {
+    firstname?: Maybe<string>
+    lastname?: Maybe<string>
+    fullname?: Maybe<string>
+  }
   updatedAt?: string
   updatedBy?: {
     id: string

+ 15 - 7
app/frontend/apps/mobile/composables/usePagination.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { computed, reactive, readonly } from 'vue'
+import { computed, reactive, readonly, ref } from 'vue'
 import { OperationVariables } from '@apollo/client/core'
 import { QueryHandler } from '@shared/server/apollo/handler'
 import {
@@ -25,15 +25,23 @@ export default function usePagination<
     return pageInfo.value.hasNextPage
   })
 
+  const loadingNextPage = ref(false)
+
   return reactive({
     pageInfo: readonly(pageInfo),
     hasNextPage: readonly(hasNextPage),
-    fetchNextPage() {
-      query.fetchMore({
-        variables: {
-          cursor: pageInfo.value?.endCursor,
-        } as Partial<TQueryVariables & PaginationVariables>,
-      })
+    loadingNextPage: readonly(loadingNextPage),
+    async fetchNextPage() {
+      try {
+        loadingNextPage.value = true
+        await query.fetchMore({
+          variables: {
+            cursor: pageInfo.value?.endCursor,
+          } as Partial<TQueryVariables & PaginationVariables>,
+        })
+      } finally {
+        loadingNextPage.value = false
+      }
     },
   })
 }

+ 1 - 2
app/frontend/apps/mobile/modules/home/__tests__/home-section-menu.spec.ts

@@ -30,8 +30,7 @@ describe('testing home section menu', () => {
     await waitForNextTick(true)
 
     await waitFor(() => {
-      // TODO: Switch to better identifier, when real ticket lists exists.
-      expect(view.getByText('Go to link Home')).toBeInTheDocument()
+      expect(view.getByText('Tickets')).toBeInTheDocument()
     })
   })
 })

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