Browse Source

Feature: Mobile - Ticket edit tag update handling.

Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Mantas Masalskis 2 years ago
parent
commit
2af811e7df

+ 67 - 0
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketTags.vue

@@ -0,0 +1,67 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { ref, toRef, watch } from 'vue'
+import { isEqual } from 'lodash-es'
+import type { FormKitContext, FormKitNode } from '@formkit/core'
+import FormGroup from '@shared/components/Form/FormGroup.vue'
+import { useTagAssignmentUpdateMutation } from '@shared/entities/tags/graphql/mutations/assignment/update.api'
+import { MutationHandler } from '@shared/server/apollo/handler'
+import type { TicketById } from '../../types/tickets'
+
+interface Props {
+  ticket: TicketById
+}
+
+const props = defineProps<Props>()
+const ticketData = toRef(props, 'ticket')
+
+const ticketTags = ref<string[]>(ticketData.value.tags || [])
+
+watch(
+  () => ticketData.value.tags,
+  () => {
+    ticketTags.value = ticketData.value.tags || []
+  },
+)
+
+const tagAssigmentUpdateHandler = new MutationHandler(
+  useTagAssignmentUpdateMutation({}),
+  {
+    errorNotificationMessage: __('Ticket tags could not be updated.'),
+  },
+)
+
+const handleChangedTicketTags = (node: FormKitNode) => {
+  node.on('dialog:afterClose', async () => {
+    const ticketId = ticketData.value.id
+
+    // Wait until the value is updated after the dialog is closed (when it's used very fast).
+    await node.settled
+
+    if (!ticketId || isEqual(ticketTags.value, ticketData.value.tags)) return
+
+    tagAssigmentUpdateHandler
+      .send({
+        objectId: ticketId,
+        tags: ticketTags.value,
+      })
+      .catch(() => {
+        // Reset tags again, when error occurs.
+        ticketTags.value = ticketData.value.tags || []
+      })
+  })
+}
+</script>
+
+<template>
+  <FormGroup>
+    <FormKit
+      v-model="ticketTags"
+      type="tags"
+      name="tags"
+      :label="__('Tags')"
+      :plugins="[handleChangedTicketTags]"
+    ></FormKit>
+  </FormGroup>
+</template>

+ 105 - 0
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketTags.spec.ts

@@ -0,0 +1,105 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { TagAssignmentUpdateDocument } from '@shared/entities/tags/graphql/mutations/assignment/update.api'
+import { renderComponent } from '@tests/support/components'
+import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
+import { waitUntil } from '@tests/support/utils'
+import TicketTags from '../TicketTags.vue'
+
+beforeAll(async () => {
+  await import('@shared/components/Form/fields/FieldTags/FieldTagsDialog.vue')
+})
+
+describe('TicketTags', () => {
+  it('renders tags field with given ticket', () => {
+    const wrapper = renderComponent(TicketTags, {
+      props: {
+        ticket: {
+          id: 1,
+          tags: ['tag1', 'tag2'],
+        },
+      },
+      form: true,
+      dialog: true,
+    })
+
+    expect(wrapper.getByLabelText('Tags')).toBeInTheDocument()
+
+    const tags = wrapper.getAllByRole('listitem')
+    expect(tags).toHaveLength(2)
+    expect(tags[0]).toHaveTextContent('tag1')
+    expect(tags[1]).toHaveTextContent('tag2')
+  })
+
+  it('can update ticket tags', async () => {
+    const mockTagAssignmentUpdateApi = mockGraphQLApi(
+      TagAssignmentUpdateDocument,
+    ).willResolve({
+      tagAssignmentUpdate: {
+        success: true,
+        errors: null,
+      },
+    })
+
+    const wrapper = renderComponent(TicketTags, {
+      props: {
+        ticket: {
+          id: 1,
+          tags: ['tag1', 'tag2'],
+        },
+      },
+      form: true,
+      dialog: true,
+    })
+
+    const tagsField = wrapper.getByLabelText('Tags')
+
+    await wrapper.events.click(tagsField)
+
+    const options = wrapper.getAllByRole('option')
+    await wrapper.events.click(options[0])
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Done' }))
+
+    await waitUntil(() => mockTagAssignmentUpdateApi.calls.resolve === 1)
+
+    const tags = wrapper.getAllByRole('listitem')
+    expect(tags).toHaveLength(1)
+    expect(tags[0]).toHaveTextContent('tag2')
+  })
+
+  it('rest ticket tags again on update error', async () => {
+    const mockTagAssignmentUpdateApi = mockGraphQLApi(
+      TagAssignmentUpdateDocument,
+    ).willFailWithError([
+      { message: 'Ticket tags not updated.', extensions: {} },
+    ])
+
+    const wrapper = renderComponent(TicketTags, {
+      props: {
+        ticket: {
+          id: 1,
+          tags: ['tag1', 'tag2'],
+        },
+      },
+      form: true,
+      dialog: true,
+    })
+
+    const tagsField = wrapper.getByLabelText('Tags')
+
+    await wrapper.events.click(tagsField)
+
+    const options = wrapper.getAllByRole('option')
+    await wrapper.events.click(options[0])
+
+    await wrapper.events.click(wrapper.getByRole('button', { name: 'Done' }))
+
+    await waitUntil(() => mockTagAssignmentUpdateApi.calls.error === 1)
+
+    const tags = wrapper.getAllByRole('listitem')
+    expect(tags).toHaveLength(2)
+    expect(tags[0]).toHaveTextContent('tag1')
+    expect(tags[1]).toHaveTextContent('tag2')
+  })
+})

+ 1 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts

@@ -49,5 +49,6 @@ export const TicketAttributesFragmentDoc = gql`
   objectAttributeValues {
     ...objectAttributeValues
   }
+  tags
 }
     ${ObjectAttributeValuesFragmentDoc}`;

+ 1 - 0
app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql

@@ -44,4 +44,5 @@ fragment ticketAttributes on Ticket {
   objectAttributeValues {
     ...objectAttributeValues
   }
+  tags
 }

+ 8 - 1
app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationDetails.vue

@@ -2,12 +2,14 @@
 
 <script setup lang="ts">
 import { onMounted, onUnmounted } from 'vue'
+import type { FormKitNode } from '@formkit/core'
 import ObjectAttributes from '@shared/components/ObjectAttributes/ObjectAttributes.vue'
 import { useObjectAttributes } from '@shared/entities/object-attributes/composables/useObjectAttributes'
 import { EnumObjectManagerObjects } from '@shared/graphql/types'
 import { getFocusableElements } from '@shared/utils/getFocusableElements'
-import type { FormKitNode } from '@formkit/core'
+import { useTicketView } from '@shared/entities/ticket/composables/useTicketView'
 import { useTicketInformation } from '../../composable/useTicketInformation'
+import TicketTags from '../../components/TicketDetailView/TicketTags.vue'
 
 const { attributes: objectAttributes } = useObjectAttributes(
   EnumObjectManagerObjects.Ticket,
@@ -42,6 +44,8 @@ onMounted(async () => {
 onUnmounted(() => {
   formVisible.value = false
 })
+
+const { isTicketAgent } = useTicketView(ticket)
 </script>
 
 <template>
@@ -59,5 +63,8 @@ onUnmounted(() => {
       group_id: 'group.name',
     }"
   />
+
+  <TicketTags v-if="isTicketAgent && ticket" :ticket="ticket" />
+
   <!-- TODO subscribe -->
 </template>

+ 28 - 20
app/frontend/shared/components/Form/fields/FieldTags/FieldTagsDialog.vue

@@ -2,11 +2,11 @@
 
 <script setup lang="ts">
 import { computed, ref, toRef } from 'vue'
+import { watchIgnorable } from '@vueuse/shared'
 import type { CommonInputSearchExpose } from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
 import CommonInputSearch from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
 import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
 import { useTraverseOptions } from '@shared/composables/useTraverseOptions'
-import { i18n } from '@shared/i18n'
 import stopEvent from '@shared/utils/events'
 import type { FieldTagsContext } from './types'
 import useValue from '../../composables/useValue'
@@ -16,11 +16,12 @@ interface Props {
   context: FieldTagsContext
 }
 
-// TODO call API to get/create tags
+// TODO: call API to list existing tags
+// TODO: we should not toggle already selected tags, when it's added twice, because it's not intuitive.
 
 const props = defineProps<Props>()
 const { localValue } = useValue(toRef(props, 'context'))
-const currentValue = computed(() => localValue.value || [])
+const currentValue = computed<string[]>(() => localValue.value || [])
 const isCurrentValue = (tag: string) => currentValue.value.includes(tag)
 
 const filter = ref('')
@@ -29,12 +30,8 @@ const newTags = ref<{ value: string; label: string }[]>([])
 const filterInput = ref<CommonInputSearchExpose>()
 const tagsListbox = ref<HTMLElement>()
 
-const translatedOptions = computed(() => {
-  const {
-    options = [],
-    noOptionsLabelTranslation,
-    sorting = 'label',
-  } = props.context
+const sortedOptions = computed(() => {
+  const { options = [], sorting = 'label' } = props.context
 
   const allOptions = [...options, ...newTags.value].sort((a, b) => {
     const a1 = (a[sorting] || '').toString()
@@ -42,29 +39,38 @@ const translatedOptions = computed(() => {
     return a1.localeCompare(b1)
   })
 
-  if (!noOptionsLabelTranslation) return allOptions
-
-  return allOptions.map((option) => {
-    option.label = i18n.t(option.label, ...(option.labelPlaceholder || []))
-    return option
-  })
+  return allOptions
 })
 
 const tagExists = (tag: string) => {
-  return translatedOptions.value.some((option) => option.value === tag)
+  return sortedOptions.value.some((option) => option.value === tag)
 }
 
+const { ignoreUpdates } = watchIgnorable(
+  currentValue,
+  (newValue) => {
+    newValue.forEach((tag) => {
+      if (!tagExists(tag)) {
+        newTags.value.push({ value: tag, label: tag })
+      }
+    })
+  },
+  { immediate: true },
+)
+
 const filteredTags = computed(() => {
-  if (!filter.value) return translatedOptions.value
+  if (!filter.value) return sortedOptions.value
 
-  return translatedOptions.value.filter((tag) =>
+  return sortedOptions.value.filter((tag) =>
     tag.label.toLowerCase().includes(filter.value.toLowerCase()),
   )
 })
 
 const removeTag = (tag: string) => {
   const newValue = currentValue.value.filter((item: string) => item !== tag)
-  props.context.node.input(newValue)
+  ignoreUpdates(() => {
+    props.context.node.input(newValue)
+  })
 }
 
 const toggleTag = (tag: string) => {
@@ -74,7 +80,9 @@ const toggleTag = (tag: string) => {
     return
   }
   normalizedValue.push(tag)
-  props.context.node.input(normalizedValue)
+  ignoreUpdates(() => {
+    props.context.node.input(normalizedValue)
+  })
 }
 
 const createTag = () => {

+ 7 - 4
app/frontend/shared/components/Form/fields/FieldTags/FieldTagsInput.vue

@@ -13,9 +13,9 @@ interface Props {
 
 const props = defineProps<Props>()
 
-const context = toRef(props, 'context')
+const reactiveContext = toRef(props, 'context')
 
-const { localValue } = useValue(context)
+const { localValue } = useValue(reactiveContext)
 
 const selectedTagsList = computed(() => {
   if (!localValue.value || !Array.isArray(localValue.value)) return []
@@ -26,12 +26,15 @@ const dialog = useDialog({
   name: `field-tags-${props.context.node.name}`,
   prefetch: true,
   component: () => import('./FieldTagsDialog.vue'),
+  afterClose: () => {
+    reactiveContext.value.node.emit('dialog:afterClose', reactiveContext.value)
+  },
 })
 
 const showDialog = () => {
   return dialog.open({
     name: dialog.name,
-    context,
+    context: reactiveContext,
   })
 }
 
@@ -40,7 +43,7 @@ const onInputClick = () => {
   showDialog()
 }
 
-useFormBlock(context, onInputClick)
+useFormBlock(reactiveContext, onInputClick)
 </script>
 
 <template>

+ 0 - 1
app/frontend/shared/components/Form/fields/FieldTags/types.ts

@@ -5,7 +5,6 @@ import type { FormFieldContext } from '../../types/field'
 export interface FieldTagsProps {
   options: FormFieldContext['options']
   disabled?: boolean
-  noOptionsLabelTranslation?: boolean
   canCreate?: boolean
   sorting?: 'label' | 'value'
 }

+ 22 - 0
app/frontend/shared/entities/tags/graphql/mutations/assignment/update.api.ts

@@ -0,0 +1,22 @@
+import * as Types from '../../../../../graphql/types';
+
+import gql from 'graphql-tag';
+import { ErrorsFragmentDoc } from '../../../../../graphql/fragments/errors.api';
+import * as VueApolloComposable from '@vue/apollo-composable';
+import * as VueCompositionApi from 'vue';
+export type ReactiveFunction<TParam> = () => TParam;
+
+export const TagAssignmentUpdateDocument = gql`
+    mutation tagAssignmentUpdate($objectId: ID!, $tags: [String!]!) {
+  tagAssignmentUpdate(objectId: $objectId, tags: $tags) {
+    success
+    errors {
+      ...errors
+    }
+  }
+}
+    ${ErrorsFragmentDoc}`;
+export function useTagAssignmentUpdateMutation(options: VueApolloComposable.UseMutationOptions<Types.TagAssignmentUpdateMutation, Types.TagAssignmentUpdateMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.TagAssignmentUpdateMutation, Types.TagAssignmentUpdateMutationVariables>>) {
+  return VueApolloComposable.useMutation<Types.TagAssignmentUpdateMutation, Types.TagAssignmentUpdateMutationVariables>(TagAssignmentUpdateDocument, options);
+}
+export type TagAssignmentUpdateMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.TagAssignmentUpdateMutation, Types.TagAssignmentUpdateMutationVariables>;

+ 8 - 0
app/frontend/shared/entities/tags/graphql/mutations/assignment/update.graphql

@@ -0,0 +1,8 @@
+mutation tagAssignmentUpdate($objectId: ID!, $tags: [String!]!) {
+  tagAssignmentUpdate(objectId: $objectId, tags: $tags) {
+    success
+    errors {
+      ...errors
+    }
+  }
+}

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