Browse Source

Feature: Mobile - Improved ticket overview handling.

Martin Gruner 2 years ago
parent
commit
5247aa8d86

+ 11 - 9
app/frontend/apps/mobile/modules/ticket/__tests__/tickets-view.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
-import { OrderDirection, TicketOrderBy } from '@shared/graphql/types'
+import { OrderDirection } from '@shared/graphql/types'
 import { waitFor } from '@testing-library/vue'
 import { visitView } from '@tests/support/components/visitView'
 import { mockTicketOverviews } from '@tests/support/mocks/ticket-overviews'
@@ -32,9 +32,11 @@ it('see default list when opening page', async () => {
     'has default overview',
   ).toHaveTextContent('Overview 1')
 
-  expect(view.getByTestId('column'), 'has default column').toHaveTextContent(
-    'Created at',
-  )
+  expect(
+    await view.findByTestId('column'),
+    'has default column',
+  ).toHaveTextContent('Created at')
+
   expect(
     view.getByIconName('long-arrow-down'),
     'descending by default',
@@ -59,7 +61,7 @@ it('can filter by overview type', async () => {
 
   expect(ticketsMock.spies.resolve).toHaveBeenCalledWith(
     expect.objectContaining({
-      orderBy: TicketOrderBy.CreatedAt,
+      orderBy: 'created_at',
       orderDirection: OrderDirection.Descending,
       overviewId: '1',
     }),
@@ -86,7 +88,7 @@ it('can filter by columns and direction', async () => {
 
   expect(ticketsMock.spies.resolve).toHaveBeenCalledWith(
     expect.objectContaining({
-      orderBy: TicketOrderBy.UpdatedAt,
+      orderBy: 'updated_at',
       orderDirection: OrderDirection.Ascending,
       overviewId: '1',
     }),
@@ -112,7 +114,7 @@ it('can filter by type and columns and direction', async () => {
   expect(ticketsMock.spies.resolve).toHaveBeenCalledWith(
     expect.objectContaining({
       overviewId: '1',
-      orderBy: TicketOrderBy.UpdatedAt,
+      orderBy: 'updated_at',
       orderDirection: OrderDirection.Ascending,
     }),
   )
@@ -124,7 +126,7 @@ it('takes filter from query', async () => {
   const ticketsMock = mockTicketsByOverview()
 
   const query = stringifyQuery({
-    column: TicketOrderBy.Number,
+    column: 'number',
     direction: OrderDirection.Ascending,
   })
   await visitView(`/tickets/view?${query}`)
@@ -133,7 +135,7 @@ it('takes filter from query', async () => {
     expect(ticketsMock.spies.resolve).toHaveBeenCalledWith(
       expect.objectContaining({
         overviewId: '1',
-        orderBy: TicketOrderBy.Number,
+        orderBy: 'number',
         orderDirection: OrderDirection.Ascending,
       }),
     )

+ 2 - 6
app/frontend/apps/mobile/modules/ticket/components/TicketList/TicketList.vue

@@ -1,11 +1,7 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import {
-  OrderDirection,
-  TicketOrderBy,
-  TicketsByOverviewQuery,
-} from '@shared/graphql/types'
+import { OrderDirection, TicketsByOverviewQuery } from '@shared/graphql/types'
 import { QueryHandler } from '@shared/server/apollo/handler'
 import usePagination from '@mobile/composables/usePagination'
 import CommonLoader from '@mobile/components/CommonLoader/CommonLoader.vue'
@@ -17,7 +13,7 @@ import { useTicketsByOverviewQuery } from '../../graphql/queries/ticketsByOvervi
 
 interface Props {
   overviewId: string
-  orderBy: TicketOrderBy
+  orderBy: string
   orderDirection: OrderDirection
 }
 

+ 1 - 1
app/frontend/apps/mobile/modules/ticket/graphql/queries/ticketsByOverview.api.ts

@@ -7,7 +7,7 @@ import * as VueCompositionApi from 'vue';
 export type ReactiveFunction<TParam> = () => TParam;
 
 export const TicketsByOverviewDocument = gql`
-    query ticketsByOverview($overviewId: ID!, $orderBy: TicketOrderBy, $orderDirection: OrderDirection, $cursor: String, $pageSize: Int = 10, $withObjectAttributes: Boolean = false) {
+    query ticketsByOverview($overviewId: ID!, $orderBy: String, $orderDirection: OrderDirection, $cursor: String, $pageSize: Int = 10, $withObjectAttributes: Boolean = false) {
   ticketsByOverview(
     overviewId: $overviewId
     orderBy: $orderBy

+ 1 - 1
app/frontend/apps/mobile/modules/ticket/graphql/queries/ticketsByOverview.graphql

@@ -1,6 +1,6 @@
 query ticketsByOverview(
   $overviewId: ID!
-  $orderBy: TicketOrderBy
+  $orderBy: String
   $orderDirection: OrderDirection
   $cursor: String
   $pageSize: Int = 10

+ 84 - 41
app/frontend/apps/mobile/modules/ticket/views/TicketOverview.vue

@@ -3,12 +3,13 @@
 <script setup lang="ts">
 import { computed, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
+import { watchOnce } from '@vueuse/shared'
 import {
   useViewTransition,
   ViewTransitions,
 } from '@mobile/components/transition/TransitionViewNavigation'
 import { i18n } from '@shared/i18n'
-import { OrderDirection, TicketOrderBy } from '@shared/graphql/types'
+import { OrderDirection } from '@shared/graphql/types'
 import CommonLoader from '@mobile/components/CommonLoader/CommonLoader.vue'
 import { useTicketsOverviews } from '@mobile/modules/home/stores/ticketOverviews'
 import CommonSelect from '@mobile/components/CommonSelect/CommonSelect.vue'
@@ -35,6 +36,13 @@ const { overviews, loading: loadingOverviews } = storeToRefs(
   useTicketsOverviews(),
 )
 
+const optionsOverviews = computed(() => {
+  return overviews.value.map((overview) => ({
+    value: overview.link,
+    label: `${i18n.t(overview.name)} (${overview.ticketCount})`,
+  }))
+})
+
 const selectedOverview = computed(() => {
   return (
     overviews.value.find((overview) => overview.link === props.overviewLink) ||
@@ -62,28 +70,72 @@ watch(
   { immediate: true },
 )
 
-// TODO when order on overview will be parsed, this should be taken from there by default
-const orderColumn = useRouteQuery<TicketOrderBy>(
-  'column',
-  TicketOrderBy.CreatedAt,
-)
-const orderDirection = useRouteQuery<OrderDirection>(
+const userOrderBy = useRouteQuery<string | undefined>('column', undefined)
+
+const orderColumnsOptions = computed(() => {
+  return (
+    selectedOverview.value?.orderColumns.map((entry) => {
+      return { value: entry.key, label: entry.value || entry.key }
+    }) || []
+  )
+})
+
+const orderColumnLabels = computed(() => {
+  const map: { [key: string]: string } = {}
+  return (
+    selectedOverview.value?.orderColumns.reduce((map, entry) => {
+      map[entry.key] = entry.value || entry.key
+      return map
+    }, map) || {}
+  )
+})
+
+// Check that the given order by column is really a valid column and otherwise
+// reset query parameter.
+watchOnce(orderColumnLabels, () => {
+  if (userOrderBy.value && !orderColumnLabels.value[userOrderBy.value]) {
+    userOrderBy.value = undefined
+  }
+})
+
+const orderBy = computed({
+  get: () => {
+    if (userOrderBy.value && orderColumnLabels.value[userOrderBy.value])
+      return userOrderBy.value
+    return selectedOverview.value?.orderBy
+  },
+  set: (column) => {
+    userOrderBy.value =
+      column !== selectedOverview.value?.orderBy ? column : undefined
+  },
+})
+
+const userOrderDirection = useRouteQuery<OrderDirection | undefined>(
   'direction',
-  OrderDirection.Descending,
+  undefined,
 )
 
-// TODO should be generated on server
-const columns: Record<TicketOrderBy, string> = {
-  [TicketOrderBy.CreatedAt]: __('Created at'),
-  [TicketOrderBy.Title]: __('Title'),
-  [TicketOrderBy.UpdatedAt]: __('Updated at'),
-  [TicketOrderBy.Number]: __('Number'),
+// Check that the given order direction is a valid direction, otherwise
+// reset the query parameter.
+if (
+  userOrderDirection.value &&
+  !Object.values(OrderDirection).includes(userOrderDirection.value)
+) {
+  userOrderDirection.value = undefined
 }
 
-const columnOptions = Object.entries(columns).map(([value, label]) => ({
-  value,
-  label,
-}))
+const orderDirection = computed({
+  get: () => {
+    if (userOrderDirection.value) return userOrderDirection.value
+    return selectedOverview.value?.orderDirection
+  },
+  set: (direction) => {
+    userOrderDirection.value =
+      direction !== selectedOverview.value?.orderDirection
+        ? direction
+        : undefined
+  },
+})
 
 const directionOptions = computed(() => [
   {
@@ -114,13 +166,6 @@ const directionOptions = computed(() => [
     },
   },
 ])
-
-const optionsOverviews = computed(() => {
-  return overviews.value.map((overview) => ({
-    value: overview.link,
-    label: `${i18n.t(overview.name)} (${overview.ticketCount})`,
-  }))
-})
 </script>
 
 <template>
@@ -148,22 +193,20 @@ const optionsOverviews = computed(() => {
         </div>
       </div>
       <div
-        v-if="loadingOverviews || optionsOverviews.length"
+        v-if="optionsOverviews.length"
         class="mb-3 flex items-center justify-between gap-2"
         data-test-id="overview"
       >
-        <CommonLoader class="w-16" :loading="loadingOverviews">
-          <FormKit
-            type="select"
-            size="small"
-            :classes="{ wrapper: 'px-0' }"
-            :model-value="selectedOverviewLink"
-            :options="optionsOverviews"
-            no-options-label-translation
-            @update:model-value="selectOverview($event as string)"
-          />
-        </CommonLoader>
-        <CommonSelect v-model="orderColumn" :options="columnOptions" no-close>
+        <FormKit
+          type="select"
+          size="small"
+          :classes="{ wrapper: 'px-0' }"
+          :model-value="selectedOverviewLink"
+          :options="optionsOverviews"
+          no-options-label-translation
+          @update:model-value="selectOverview($event as string)"
+        />
+        <CommonSelect v-model="orderBy" :options="orderColumnsOptions" no-close>
           <template #default="{ open }">
             <div
               class="flex cursor-pointer items-center gap-1 whitespace-nowrap text-blue"
@@ -178,7 +221,7 @@ const optionsOverviews = computed(() => {
                 }"
                 :fixed-size="{ width: 12, height: 12 }"
               />
-              {{ orderColumn && $t(columns[orderColumn]) }}
+              {{ orderBy && $t(orderColumnLabels[orderBy]) }}
             </div>
           </template>
 
@@ -216,9 +259,9 @@ const optionsOverviews = computed(() => {
       :loading="loadingOverviews"
     >
       <TicketList
-        v-if="selectedOverview && orderColumn"
+        v-if="selectedOverview && orderBy && orderDirection"
         :overview-id="selectedOverview.id"
-        :order-by="orderColumn"
+        :order-by="orderBy"
         :order-direction="orderDirection"
       />
     </CommonLoader>

+ 10 - 2
app/frontend/shared/entities/ticket/graphql/queries/overviews.api.ts

@@ -14,8 +14,16 @@ export const OverviewsDocument = gql`
         name
         link
         prio
-        order
-        view
+        orderBy
+        orderDirection
+        viewColumns {
+          key
+          value
+        }
+        orderColumns {
+          key
+          value
+        }
         active
         ticketCount @include(if: $withTicketCount)
       }

+ 10 - 2
app/frontend/shared/entities/ticket/graphql/queries/overviews.graphql

@@ -6,8 +6,16 @@ query overviews($withTicketCount: Boolean!) {
         name
         link
         prio
-        order
-        view
+        orderBy
+        orderDirection
+        viewColumns {
+          key
+          value
+        }
+        orderColumns {
+          key
+          value
+        }
         active
         ticketCount @include(if: $withTicketCount)
       }

+ 21 - 22
app/frontend/shared/graphql/types.ts

@@ -96,6 +96,13 @@ export type KeyComplexValue = {
   value?: Maybe<Scalars['JSON']>;
 };
 
+/** Key/value type with string values. */
+export type KeyValue = {
+  __typename?: 'KeyValue';
+  key: Scalars['String'];
+  value?: Maybe<Scalars['String']>;
+};
+
 /** Locales available in the system */
 export type Locale = Node & {
   __typename?: 'Locale';
@@ -154,7 +161,7 @@ export type LogoutPayload = {
   success: Scalars['Boolean'];
 };
 
-/** All available mutations. */
+/** All available mutations */
 export type Mutations = {
   __typename?: 'Mutations';
   /** Update the language of the currently logged in user */
@@ -170,27 +177,27 @@ export type Mutations = {
 };
 
 
-/** All available mutations. */
+/** All available mutations */
 export type MutationsAccountLocaleArgs = {
   locale: Scalars['String'];
 };
 
 
-/** All available mutations. */
+/** All available mutations */
 export type MutationsFormUploadCacheAddArgs = {
   files: Array<UploadFileInput>;
   formId: Scalars['FormId'];
 };
 
 
-/** All available mutations. */
+/** All available mutations */
 export type MutationsFormUploadCacheRemoveArgs = {
   fileIds: Array<Scalars['ID']>;
   formId: Scalars['FormId'];
 };
 
 
-/** All available mutations. */
+/** All available mutations */
 export type MutationsLoginArgs = {
   input: LoginInput;
 };
@@ -287,7 +294,10 @@ export type Overview = Node & {
   id: Scalars['ID'];
   link: Scalars['String'];
   name: Scalars['String'];
-  order: Scalars['String'];
+  orderBy: Scalars['String'];
+  /** Columns that may be used as order_by of overview queries, with assigned label values */
+  orderColumns: Array<KeyValue>;
+  orderDirection: OrderDirection;
   prio: Scalars['Int'];
   /** Count of tickets the authenticated user may see in this overview */
   ticketCount: Scalars['Int'];
@@ -295,7 +305,8 @@ export type Overview = Node & {
   updatedAt: Scalars['ISO8601DateTime'];
   /** Last user that updated this record */
   updatedBy: User;
-  view: Scalars['String'];
+  /** Columns to be shown on screen, with assigned label values */
+  viewColumns: Array<KeyValue>;
 };
 
 /** The connection type for Overview. */
@@ -447,7 +458,7 @@ export type QueriesTicketsByOverviewArgs = {
   before?: InputMaybe<Scalars['String']>;
   first?: InputMaybe<Scalars['Int']>;
   last?: InputMaybe<Scalars['Int']>;
-  orderBy?: InputMaybe<TicketOrderBy>;
+  orderBy?: InputMaybe<Scalars['String']>;
   orderDirection?: InputMaybe<OrderDirection>;
   overviewId: Scalars['ID'];
 };
@@ -639,18 +650,6 @@ export type TicketEdge = {
   node: Ticket;
 };
 
-/** Option to choose ticket field for SQL sorting */
-export enum TicketOrderBy {
-  /** Sort by create time */
-  CreatedAt = 'CREATED_AT',
-  /** Sort by ticket number */
-  Number = 'NUMBER',
-  /** Sort by title */
-  Title = 'TITLE',
-  /** Sort by update time */
-  UpdatedAt = 'UPDATED_AT'
-}
-
 /** Ticket priorities */
 export type TicketPriority = Node & {
   __typename?: 'TicketPriority';
@@ -845,7 +844,7 @@ export type TicketArticlesQuery = { __typename?: 'Queries', ticketArticles: { __
 
 export type TicketsByOverviewQueryVariables = Exact<{
   overviewId: Scalars['ID'];
-  orderBy?: InputMaybe<TicketOrderBy>;
+  orderBy?: InputMaybe<Scalars['String']>;
   orderDirection?: InputMaybe<OrderDirection>;
   cursor?: InputMaybe<Scalars['String']>;
   pageSize?: InputMaybe<Scalars['Int']>;
@@ -883,7 +882,7 @@ export type OverviewsQueryVariables = Exact<{
 }>;
 
 
-export type OverviewsQuery = { __typename?: 'Queries', overviews: { __typename?: 'OverviewConnection', edges: Array<{ __typename?: 'OverviewEdge', cursor: string, node: { __typename?: 'Overview', id: string, name: string, link: string, prio: number, order: string, view: string, active: boolean, ticketCount?: number } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean } } };
+export type OverviewsQuery = { __typename?: 'Queries', overviews: { __typename?: 'OverviewConnection', edges: Array<{ __typename?: 'OverviewEdge', cursor: string, node: { __typename?: 'Overview', id: string, name: string, link: string, prio: number, orderBy: string, orderDirection: OrderDirection, active: boolean, ticketCount?: number, viewColumns: Array<{ __typename?: 'KeyValue', key: string, value?: string | null }>, orderColumns: Array<{ __typename?: 'KeyValue', key: string, value?: string | null }> } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean } } };
 
 export type ErrorsFragment = { __typename?: 'UserError', message: string, field?: string | null };
 

+ 25 - 1
app/frontend/tests/support/mocks/ticket-overviews.ts

@@ -1,7 +1,7 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
 import { OverviewsDocument } from '@shared/entities/ticket/graphql/queries/overviews.api'
-import { OverviewsQuery } from '@shared/graphql/types'
+import { OrderDirection, OverviewsQuery } from '@shared/graphql/types'
 import { mock } from 'vitest-mock-extended'
 import { mockGraphQLApi } from '../mock-graphql-api'
 
@@ -20,6 +20,14 @@ export const getApiTicketOverviews = (): OverviewsQuery => ({
             name: __('Overview 1'),
             link: 'overview_1',
             ticketCount: 1,
+            orderBy: 'created_at',
+            orderDirection: OrderDirection.Descending,
+            orderColumns: [
+              { key: 'number', value: 'Number' },
+              { key: 'title', value: 'Title' },
+              { key: 'created_at', value: 'Created at' },
+              { key: 'updated_at', value: 'Updated at' },
+            ],
           },
         },
         {
@@ -29,6 +37,14 @@ export const getApiTicketOverviews = (): OverviewsQuery => ({
             name: __('Overview 2'),
             link: 'overview_2',
             ticketCount: 2,
+            orderBy: 'created_at',
+            orderDirection: OrderDirection.Ascending,
+            orderColumns: [
+              { key: 'number', value: 'Number' },
+              { key: 'title', value: 'Title' },
+              { key: 'created_at', value: 'Created at' },
+              { key: 'updated_at', value: 'Updated at' },
+            ],
           },
         },
         {
@@ -38,6 +54,14 @@ export const getApiTicketOverviews = (): OverviewsQuery => ({
             name: __('Overview 3'),
             link: 'overview_3',
             ticketCount: 3,
+            orderBy: 'created_at',
+            orderDirection: OrderDirection.Ascending,
+            orderColumns: [
+              { key: 'number', value: 'Number' },
+              { key: 'title', value: 'Title' },
+              { key: 'created_at', value: 'Created at' },
+              { key: 'updated_at', value: 'Updated at' },
+            ],
           },
         },
       ],

+ 1 - 1
app/graphql/gql/entry_points/mutations.rb

@@ -3,7 +3,7 @@
 module Gql::EntryPoints
   class Mutations < Gql::Types::BaseObject
 
-    description 'All available mutations.'
+    description 'All available mutations'
 
     Mixin::RequiredSubPaths.eager_load_recursive Gql::Mutations, "#{__dir__}/../mutations/"
     Gql::Mutations::BaseMutation.descendants.reject { |klass| klass.name.include?('::Base') }.each do |klass|

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