Browse Source

Feature: Mobile - Added ticket article add/edit dialog for the ticket detail view.

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Dominik Klein 2 years ago
parent
commit
02f1450611

+ 1 - 1
app/frontend/apps/mobile/components/CommonDialogObjectForm/CommonDialogObjectForm.vue

@@ -122,7 +122,7 @@ const saveObject = async (formData: FormData) => {
   <CommonDialog class="w-full" no-autofocus :name="name">
     <template #before-label>
       <button
-        class="text-blue"
+        class="text-white"
         :disabled="isDisabled"
         :class="{ 'opacity-50': isDisabled }"
         @click="cancelDialog"

+ 0 - 15
app/frontend/apps/mobile/entities/user/__tests__/mocks/user-mocks.ts

@@ -66,13 +66,6 @@ export const defaultUser = (): ConfidentTake<UserQuery, 'user'> => {
         attribute: {
           name: 'department',
           display: 'Department',
-          dataType: 'input',
-          dataOption: {
-            type: 'text',
-            maxlength: 200,
-            null: true,
-            item_class: 'formGroup--halfSize',
-          },
           __typename: 'ObjectManagerFrontendAttribute',
         },
         value: '',
@@ -83,14 +76,6 @@ export const defaultUser = (): ConfidentTake<UserQuery, 'user'> => {
         attribute: {
           name: 'address',
           display: 'Address',
-          dataType: 'textarea',
-          dataOption: {
-            type: 'text',
-            maxlength: 500,
-            rows: 4,
-            null: true,
-            item_class: 'formGroup--halfSize',
-          },
           __typename: 'ObjectManagerFrontendAttribute',
         },
         value: '',

+ 1 - 0
app/frontend/apps/mobile/form/theme/global/addBlockFloatingLabel.ts

@@ -38,6 +38,7 @@ export const addBlockFloatingLabel = (classes: Classes = {}): Classes => {
       focus:outline-none
       placeholder:text-transparent
       pt-6
+      formkit-label-hidden:pt-4
     `),
     label: clean(`
       ${label}

+ 1 - 20
app/frontend/apps/mobile/form/theme/global/getCoreClasses.ts

@@ -24,25 +24,6 @@ export const addButtonVariants = (classes: Classes = {}): Classes => {
   }
 }
 
-export const addSelectLabel = (classes: Classes = {}): Classes => {
-  const {
-    label = '',
-    arrow = '',
-    outer = '',
-    wrapper = '',
-    inner = '',
-  } = classes
-
-  return addBlockFloatingLabel({
-    ...classes,
-    label: `${label} formkit-label-hidden:hidden`,
-    arrow: `${arrow} formkit-label-hidden:hidden`,
-    outer: `${outer} formkit-label-hidden:!min-h-[initial] formkit-label-hidden:!p-0`,
-    wrapper: `${wrapper} formkit-label-hidden:!py-0`,
-    inner: `${inner} formkit-label-hidden:!p-0`,
-  })
-}
-
 const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
   return {
     global: {},
@@ -74,7 +55,7 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
       inner: `${classes.toggle?.inner || ''} flex items-center h-full`,
     }),
     tags: addBlockFloatingLabel(classes.tags),
-    select: addSelectLabel(classes.select),
+    select: addBlockFloatingLabel(classes.select),
     treeselect: addBlockFloatingLabel(classes.treeselect),
     autocomplete: addBlockFloatingLabel(classes.autocomplete),
     customer: addBlockFloatingLabel(classes.customer),

+ 0 - 2
app/frontend/apps/mobile/pages/ticket/__tests__/ticket-create.spec.ts

@@ -221,8 +221,6 @@ describe('Creating new ticket as agent', () => {
       const editorNode = getNode('body')
       await editorNode?.input('Article body', false)
 
-      await waitUntil(() => mockFormUpdater.calls.resolve === 5)
-
       // there is button with "arrow up" and actual button
       const submitButton = view.getAllByRole('button', {
         name: 'Create ticket',

+ 0 - 20
app/frontend/apps/mobile/pages/ticket/__tests__/ticket-information-update.spec.ts

@@ -9,7 +9,6 @@ import {
 } from '@tests/support/mock-graphql-api'
 import { ObjectManagerFrontendAttributesDocument } from '@shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.api'
 import { waitUntil } from '@tests/support/utils'
-import { waitFor } from '@testing-library/vue'
 import { getNode } from '@formkit/core'
 import {
   mockUserGql,
@@ -96,25 +95,6 @@ describe('updating ticket information', () => {
     ).not.toBeInTheDocument()
   })
 
-  it('title has focus', async () => {
-    const { view } = await visitTicketInformation()
-
-    expect(view.getByLabelText('Ticket title')).toHaveFocus()
-
-    const { mockUser } = mockUserGql()
-    mockGraphQLSubscription(UserUpdatesDocument)
-
-    await view.events.click(view.getByRole('tab', { name: 'Customer' }))
-
-    await waitUntil(() => mockUser.calls.resolve)
-
-    await view.events.click(view.getByRole('tab', { name: 'Ticket' }))
-
-    await waitFor(() => {
-      expect(view.getByLabelText('Ticket title')).toHaveFocus()
-    })
-  })
-
   it('shows confirm popup, when leaving', async () => {
     const { view } = await visitTicketInformation()
 

+ 146 - 0
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleReplyDialog.vue

@@ -0,0 +1,146 @@
+<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import type { ShallowRef } from 'vue'
+import type { FormKitNode } from '@formkit/core'
+import { cloneDeep, isEqual } from 'lodash-es'
+import { computed, onMounted, onUnmounted } from 'vue'
+import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
+import { closeDialog } from '@shared/composables/useDialog'
+import type { TicketById } from '@shared/entities/ticket/types'
+import type { FormRef } from '@shared/components/Form'
+import { useConfirmationDialog } from '@mobile/components/CommonConfirmation'
+
+interface Props {
+  name: string
+  ticket: TicketById
+  articleFormGroupNode: FormKitNode
+  newTicketArticlePresent: boolean
+  form: ShallowRef<FormRef | undefined>
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  (e: 'showArticleForm'): void
+  (e: 'hideArticleForm'): void
+  (e: 'discard'): void
+  (e: 'done'): void
+}>()
+
+const label = computed(() =>
+  props.newTicketArticlePresent ? __('Edit reply') : __('Add reply'),
+)
+
+const { waitForConfirmation } = useConfirmationDialog()
+
+const articleFormGroupNodeContext = computed(
+  () => props.articleFormGroupNode.context,
+)
+
+const rememberArticleFormData = cloneDeep(
+  articleFormGroupNodeContext.value?._value,
+)
+
+const dialogFormIsDirty = computed(() => {
+  if (!props.newTicketArticlePresent)
+    return !!articleFormGroupNodeContext.value?.state.dirty
+
+  console.log(
+    'rememberArticleFormData',
+    rememberArticleFormData,
+    articleFormGroupNodeContext.value?._value,
+  )
+
+  return !isEqual(
+    rememberArticleFormData,
+    articleFormGroupNodeContext.value?._value,
+  )
+})
+
+const cancelDialog = async () => {
+  if (dialogFormIsDirty.value) {
+    const confirmed = await waitForConfirmation(
+      __('Are you sure? You have changes that will get lost.'),
+    )
+
+    if (!confirmed) return
+  }
+
+  // Set article form data back to the remembered state.
+  // For the first time we need to do nothing, because the article
+  // group will be removed again from the form.
+  if (props.newTicketArticlePresent) {
+    props.articleFormGroupNode.input(rememberArticleFormData)
+  }
+
+  closeDialog(props.name)
+}
+
+const discardDialog = async () => {
+  const confirmed = await waitForConfirmation(
+    __('Are you sure? You current article preperation will be removed.'),
+  )
+
+  if (!confirmed) return
+
+  // Reset only the article group.
+  props.articleFormGroupNode.reset()
+
+  emit('discard')
+  closeDialog(props.name)
+}
+
+onMounted(async () => {
+  emit('showArticleForm')
+})
+
+onUnmounted(() => {
+  emit('hideArticleForm')
+})
+
+const close = () => {
+  emit('done')
+  closeDialog(props.name)
+}
+
+console.log(
+  'props.articleFormGroupNode',
+  articleFormGroupNodeContext.value?.state,
+)
+</script>
+
+<template>
+  <CommonDialog class="w-full" no-autofocus :name="name" :label="label">
+    <template #before-label>
+      <button class="text-white" @click="cancelDialog">
+        {{ $t('Cancel') }}
+      </button>
+    </template>
+    <template #after-label>
+      <button
+        class="grow text-blue"
+        tabindex="0"
+        role="button"
+        @pointerdown.stop
+        @click="close()"
+        @keypress.space.prevent="close()"
+      >
+        {{ $t('Done') }}
+      </button>
+    </template>
+    <div class="w-full p-4">
+      <div data-ticket-article-reply-form />
+      <FormKit
+        v-if="newTicketArticlePresent"
+        wrapper-class="mt-4 flex grow justify-center items-center"
+        input-class="py-2 px-4 w-full h-14 text-base text-red-bright formkit-variant-primary:bg-red-dark rounded-xl select-none"
+        type="button"
+        name="discardArticle"
+        @click="discardDialog"
+      >
+        {{ $t('Discard your unsaved changes') }}
+      </FormKit>
+    </div>
+  </CommonDialog>
+</template>

+ 0 - 6
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue

@@ -59,12 +59,6 @@ const filterAttachments = (article: TicketArticle) => {
     aria-label="Articles"
     class="relative flex-1 space-y-5 px-4 pt-4"
   >
-    <!-- TODO counter indicator, use role="timer" -->
-    <!-- <button
-      class="absolute -top-7 right-4 flex h-14 w-14 items-center justify-center rounded-full bg-yellow text-black"
-    >
-      <CommonIcon name="mobile-arrow-up" />
-    </button> -->
     <template v-for="row in rows" :key="row.key">
       <ArticleBubble
         v-if="row.type === 'article-bubble'"

+ 15 - 2
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewReplyButton.vue

@@ -1,9 +1,22 @@
 <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
 
+<script setup lang="ts">
+import { useTicketInformation } from '../../composable/useTicketInformation'
+
+const { newTicketArticlePresent, showArticleReplyDialog } =
+  useTicketInformation()
+</script>
+
 <template>
   <button
-    class="fixed bottom-0 flex h-16 w-screen items-center justify-center rounded-t-lg bg-gray-600 pb-4"
+    class="fixed bottom-0 flex h-12 w-screen items-center justify-center rounded-t-lg bg-gray-600 pb-1"
+    @click="showArticleReplyDialog"
   >
-    {{ $t('Add reply') }}
+    <template v-if="newTicketArticlePresent">
+      {{ $t('Edit reply') }}
+    </template>
+    <template v-else>
+      {{ $t('Add reply') }}
+    </template>
   </button>
 </template>

+ 74 - 38
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewTitle.vue

@@ -1,11 +1,23 @@
 <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
 import CommonTicketPriorityIndicator from '@shared/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicator.vue'
 import CommonUserAvatar from '@shared/components/CommonUserAvatar/CommonUserAvatar.vue'
 import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
-import { computed } from 'vue'
 import type { TicketById } from '@shared/entities/ticket/types'
+import TicketDetailViewUpdateButton from './TicketDetailViewUpdateButton.vue'
+import { useTicketInformation } from '../../composable/useTicketInformation'
+
+const {
+  ticket,
+  newTicketArticlePresent,
+  isTicketFormGroupValid,
+  isArticleFormGroupValid,
+  formSubmit,
+  showArticleReplyDialog,
+} = useTicketInformation()
 
 interface Props {
   ticket: TicketById
@@ -13,6 +25,8 @@ interface Props {
 
 const props = defineProps<Props>()
 
+const router = useRouter()
+
 const customer = computed(() => {
   const { customer } = props.ticket
   if (!customer) return ''
@@ -20,48 +34,70 @@ const customer = computed(() => {
   if (fullname === '-') return ''
   return fullname
 })
+
+const submitForm = () => {
+  if (!isTicketFormGroupValid.value) {
+    router.push(`/tickets/${props.ticket.internalId}/information`)
+  } else if (newTicketArticlePresent.value && !isArticleFormGroupValid.value) {
+    showArticleReplyDialog()
+  }
+
+  formSubmit()
+}
 </script>
 
 <template>
-  <CommonLink
-    class="flex border-b-[0.5px] border-white/10 bg-gray-600/90 py-5 px-4"
-    data-test-id="title-content"
-    :link="`/tickets/${ticket.internalId}/information`"
-  >
-    <div class="ltr:mr-3 rtl:ml-3">
-      <CommonUserAvatar :entity="ticket.customer" />
-    </div>
-    <div class="overflow-hidden">
-      <div class="flex text-sm leading-4 text-gray-100">
-        <div
-          class="overflow-hidden text-ellipsis whitespace-nowrap"
-          :class="{
-            'max-w-[80vw]': !ticket.organization,
-            'max-w-[40vw]': ticket.organization,
-          }"
-        >
-          {{ customer }}
-        </div>
-        <template v-if="ticket.organization">
-          <div class="px-1">·</div>
+  <div class="relative border-b-[0.5px] border-white/10 bg-gray-600/90">
+    <CommonLink
+      class="flex py-5 px-4"
+      data-test-id="title-content"
+      :link="`/tickets/${ticket.internalId}/information`"
+    >
+      <div class="ltr:mr-3 rtl:ml-3">
+        <CommonUserAvatar :entity="ticket.customer" />
+      </div>
+      <div class="overflow-hidden ltr:mr-1 rtl:ml-1">
+        <div class="flex text-sm leading-4 text-gray-100">
           <div
-            class="max-w-[40vw] overflow-hidden text-ellipsis whitespace-nowrap"
+            class="overflow-hidden text-ellipsis whitespace-nowrap"
+            :class="{
+              'max-w-[80vw]': !ticket.organization,
+              'max-w-[40vw]': ticket.organization,
+            }"
           >
-            {{ ticket.organization.name }}
+            {{ customer }}
           </div>
-        </template>
-      </div>
-      <h1 class="break-words text-xl font-bold leading-7 line-clamp-3">
-        {{ ticket.title }}
-      </h1>
-      <div class="mt-2 flex gap-2">
-        <CommonTicketStateIndicator
-          :status="ticket.state.stateType.name"
-          :label="ticket.state.name"
-          pill
-        />
-        <CommonTicketPriorityIndicator :priority="ticket.priority" />
+          <template v-if="ticket.organization">
+            <div class="px-1">·</div>
+            <div
+              class="max-w-[40vw] overflow-hidden text-ellipsis whitespace-nowrap"
+            >
+              {{ ticket.organization.name }}
+            </div>
+          </template>
+        </div>
+        <h1 class="break-words text-xl font-bold leading-7 line-clamp-3">
+          {{ ticket.title }}
+        </h1>
+        <div class="mt-2 flex gap-2">
+          <CommonTicketStateIndicator
+            :status="ticket.state.stateType.name"
+            :label="ticket.state.name"
+            pill
+          />
+          <CommonTicketPriorityIndicator :priority="ticket.priority" />
+        </div>
       </div>
-    </div>
-  </CommonLink>
+      <CommonIcon
+        name="mobile-chevron-right"
+        size="base"
+        class="shrink-0 self-center ltr:ml-auto ltr:-mr-2 rtl:mr-auto"
+        decorative
+      />
+    </CommonLink>
+    <TicketDetailViewUpdateButton
+      class="!absolute right-4 -bottom-5"
+      @click.prevent="submitForm"
+    />
+  </div>
 </template>

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