Browse Source

Feature: Mobile - Implement ticket create improvements.

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Florian Liebe 2 years ago
parent
commit
426b5fb786

+ 188 - 0
app/frontend/apps/mobile/entities/ticket/__tests__/mocks/ticket-mocks.ts

@@ -3,6 +3,7 @@
 import { ObjectManagerFrontendAttributesDocument } from '@shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.api'
 import type { ObjectManagerFrontendAttributesPayload } from '@shared/graphql/types'
 import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
+import { nullableMock } from '@tests/support/utils'
 
 export const ticketObjectAttributes = () => ({
   attributes: [
@@ -270,6 +271,136 @@ export const ticketObjectAttributes = () => ({
   __typename: 'ObjectManagerFrontendAttributesPayload',
 })
 
+export const ticketArticleObjectAttributes = () => ({
+  attributes: [
+    {
+      name: 'type_id',
+      display: 'Type',
+      dataType: 'select',
+      dataOption: {
+        relation: 'TicketArticleType',
+        nulloption: false,
+        multiple: false,
+        null: false,
+        default: 10,
+        translate: true,
+        maxlength: 255,
+        belongs_to: 'type',
+      },
+      isInternal: true,
+      screens: {
+        create_middle: {},
+        edit: {
+          null: false,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
+    {
+      name: 'internal',
+      display: 'Visibility',
+      dataType: 'select',
+      dataOption: {
+        options: {
+          true: 'internal',
+          false: 'public',
+        },
+        nulloption: false,
+        multiple: false,
+        null: true,
+        default: false,
+        translate: true,
+        maxlength: 255,
+      },
+      isInternal: true,
+      screens: {
+        create_middle: {},
+        edit: {
+          null: false,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
+    {
+      name: 'to',
+      display: 'To',
+      dataType: 'input',
+      dataOption: {
+        type: 'text',
+        maxlength: 1000,
+        null: true,
+      },
+      isInternal: true,
+      screens: {
+        create_middle: {},
+        edit: {
+          null: true,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
+    {
+      name: 'cc',
+      display: 'CC',
+      dataType: 'input',
+      dataOption: {
+        type: 'text',
+        maxlength: 1000,
+        null: true,
+      },
+      isInternal: true,
+      screens: {
+        create_top: {},
+        create_middle: {},
+        edit: {
+          null: true,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
+    {
+      name: 'body',
+      display: 'Text',
+      dataType: 'richtext',
+      dataOption: {
+        type: 'richtext',
+        maxlength: 150000,
+        upload: true,
+        rows: 8,
+        null: true,
+      },
+      isInternal: true,
+      screens: {
+        create_top: {
+          null: false,
+        },
+        edit: {
+          null: true,
+        },
+      },
+      __typename: 'ObjectManagerFrontendAttribute',
+    },
+  ],
+  screens: [
+    {
+      name: 'create_middle',
+      attributes: [],
+      __typename: 'ObjectManagerScreenAttributes',
+    },
+    {
+      name: 'edit',
+      attributes: ['type_id', 'internal', 'to', 'cc', 'body'],
+      __typename: 'ObjectManagerScreenAttributes',
+    },
+    {
+      name: 'create_top',
+      attributes: ['body'],
+      __typename: 'ObjectManagerScreenAttributes',
+    },
+  ],
+  __typename: 'ObjectManagerFrontendAttributesPayload',
+})
+
 export const mockTicketObjectAttributesGql = (
   attributes?: ObjectManagerFrontendAttributesPayload,
 ) => {
@@ -277,3 +408,60 @@ export const mockTicketObjectAttributesGql = (
     objectManagerFrontendAttributes: attributes || ticketObjectAttributes(),
   })
 }
+
+export const ticketPayload = (id = 1) =>
+  nullableMock({
+    id: `gid://zammad/Ticket/${id}`,
+    internalId: id,
+    number: 7800 + id,
+    title: 'Ticket Title',
+    createdAt: '2022-11-30T12:40:15Z',
+    updatedAt: '2022-11-30T12:40:15Z',
+    pendingTime: null,
+    owner: {
+      id: 'gid://zammad/User/1',
+      internalId: 1,
+      firstname: '-',
+      lastname: '',
+      __typename: 'User',
+    },
+    customer: {
+      id: 'gid://zammad/User/2',
+      internalId: 2,
+      firstname: 'Nicole',
+      lastname: 'Braun',
+      fullname: 'Nicole Braun',
+      __typename: 'User',
+    },
+    organization: {
+      id: 'gid://zammad/Organization/1',
+      internalId: 1,
+      name: 'Zammad Foundation',
+      __typename: 'Organization',
+    },
+    state: {
+      id: 'gid://zammad/Ticket::State/2',
+      name: 'open',
+      stateType: {
+        name: 'open',
+        __typename: 'TicketStateType',
+      },
+      __typename: 'TicketState',
+    },
+    group: {
+      id: 'gid://zammad/Group/1',
+      name: 'Users',
+      __typename: 'Group',
+    },
+    priority: {
+      id: 'gid://zammad/Ticket::Priority/2',
+      name: '2 normal',
+      defaultCreate: true,
+      uiColor: null,
+      __typename: 'TicketPriority',
+    },
+    objectAttributeValues: [],
+    tags: null,
+    subscribed: false,
+    __typename: 'Ticket',
+  })

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

@@ -0,0 +1,338 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import {
+  ticketObjectAttributes,
+  ticketArticleObjectAttributes,
+  ticketPayload,
+} from '@mobile/entities/ticket/__tests__/mocks/ticket-mocks'
+import { defaultOrganization } from '@mobile/entities/organization/__tests__/mocks/organization-mocks'
+import { FormUpdaterDocument } from '@shared/components/Form/graphql/queries/formUpdater.api'
+import { ObjectManagerFrontendAttributesDocument } from '@shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.api'
+import { visitView } from '@tests/support/components/visitView'
+import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
+import { mockPermissions } from '@tests/support/mock-permissions'
+import { mockAccount } from '@tests/support/mock-account'
+import type { ExtendedRenderResult } from '@tests/support/components'
+import { mockApplicationConfig } from '@tests/support/mock-applicationConfig'
+import { flushPromises } from '@vue/test-utils'
+import { nullableMock, waitUntil } from '@tests/support/utils'
+import { getTestRouter } from '@tests/support/components/renderComponent'
+import { getNode } from '@formkit/core'
+import { AutocompleteSearchUserDocument } from '@shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/user.api'
+import { TicketCreateDocument } from '../graphql/mutations/create.api'
+
+const visitTicketCreate = async () => {
+  const mockObjectAttributes = mockGraphQLApi(
+    ObjectManagerFrontendAttributesDocument,
+  ).willBehave(({ object }) => {
+    if (object === 'Ticket') {
+      return {
+        data: {
+          objectManagerFrontendAttributes: ticketObjectAttributes(),
+        },
+      }
+    }
+
+    return {
+      data: {
+        objectManagerFrontendAttributes: ticketArticleObjectAttributes(),
+      },
+    }
+  })
+
+  const mockFormUpdater = mockGraphQLApi(FormUpdaterDocument).willResolve({
+    formUpdater: {
+      group_id: {
+        show: true,
+        options: [
+          {
+            label: 'Users',
+            value: 1,
+          },
+        ],
+        clearable: true,
+      },
+      owner_id: {
+        show: true,
+        options: [{ value: 100, label: 'Max Mustermann' }],
+      },
+      priority_id: {
+        show: true,
+        options: [
+          { value: 1, label: '1 low' },
+          { value: 2, label: '2 normal' },
+          { value: 3, label: '3 high' },
+        ],
+        clearable: true,
+      },
+      pending_time: {
+        show: false,
+        required: false,
+        hidden: false,
+        disabled: false,
+      },
+      state_id: {
+        show: true,
+        options: [
+          { value: 4, label: 'closed' },
+          { value: 2, label: 'open' },
+          { value: 7, label: 'pending close' },
+          { value: 3, label: 'pending reminder' },
+        ],
+        clearable: true,
+      },
+    },
+  })
+
+  const view = await visitView('/tickets/create')
+
+  await flushPromises()
+
+  return { mockFormUpdater, mockObjectAttributes, view }
+}
+
+const mockTicketCreate = () => {
+  return mockGraphQLApi(TicketCreateDocument).willResolve({
+    ticketCreate: {
+      ticket: ticketPayload(),
+      errors: null,
+      __typename: 'TicketCreatePayload',
+    },
+  })
+}
+
+const mockCustomerQueryResult = () => {
+  return mockGraphQLApi(AutocompleteSearchUserDocument).willResolve({
+    autocompleteSearchUser: [
+      nullableMock({
+        value: '2',
+        label: 'Nicole Braun',
+        labelPlaceholder: null,
+        heading: 'Zammad Foundation',
+        headingPlaceholder: null,
+        disabled: null,
+        icon: null,
+        user: {
+          id: 'gid://zammad/User/2',
+          internalId: 2,
+          firstname: 'Nicole',
+          lastname: 'Braun',
+          fullname: 'Nicole Braun',
+          image: null,
+          objectAttributeValues: [],
+          organization: {
+            id: 'gid://zammad/Organization/1',
+            internalId: 1,
+            name: 'Zammad Foundation',
+            active: true,
+            objectAttributeValues: [],
+            __typename: 'Organization',
+          },
+          hasSecondaryOrganizations: false,
+          __typename: 'User',
+        },
+        __typename: 'AutocompleteSearchUserEntry',
+      }),
+    ],
+  })
+}
+
+const nextStep = async (view: ExtendedRenderResult) => {
+  await view.events.click(view.getByRole('button', { name: 'Continue' }))
+}
+
+describe('Creating new ticket as agent', () => {
+  beforeEach(() => {
+    mockPermissions(['ticket.agent'])
+
+    mockApplicationConfig({
+      customer_ticket_create: true,
+      ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
+      ui_ticket_create_default_type: 'phone-in',
+    })
+  })
+
+  it('shows 4 steps for agents', async () => {
+    const { view } = await visitTicketCreate()
+
+    const steps = ['1', '2', '3', '4']
+    steps.forEach((step) => {
+      expect(view.getByRole('button', { name: step })).toBeInTheDocument()
+    })
+  })
+
+  it('disables the submit button if required data is missing', async () => {
+    const { view } = await visitTicketCreate()
+
+    expect(view.getByRole('button', { name: 'Create ticket' })).toBeDisabled()
+  })
+
+  it('invalidates a single step if required data is missing', async () => {
+    const { mockFormUpdater, view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Foobar')
+
+    await waitUntil(() => mockFormUpdater.calls.resolve)
+
+    await nextStep(view)
+    await nextStep(view)
+    await nextStep(view)
+
+    expect(
+      view.getByRole('status', { name: 'Invalid values in step 3' }),
+    ).toBeInTheDocument()
+  })
+
+  it('redirects to detail view after successful ticket creation', async () => {
+    const mockCustomer = mockCustomerQueryResult()
+    const mockTicket = mockTicketCreate()
+
+    const { mockFormUpdater, view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Ticket Title')
+    await waitUntil(() => mockFormUpdater.calls.resolve === 2)
+
+    await nextStep(view)
+    await nextStep(view)
+
+    // Customer selection.
+    await view.events.click(view.getByLabelText('Customer'))
+    await view.events.type(view.getByRole('searchbox'), 'nicole')
+
+    await waitUntil(() => mockCustomer.calls.resolve)
+
+    await view.events.click(view.getByText('Nicole Braun'))
+
+    await waitUntil(() => mockFormUpdater.calls.resolve === 3)
+
+    // Group selection.
+    await view.events.click(view.getByLabelText('Group'))
+    await view.events.click(view.getByText('Users'))
+    await waitUntil(() => mockFormUpdater.calls.resolve === 4)
+
+    await nextStep(view)
+
+    // Text input.
+    const editorNode = getNode('body')
+    await editorNode?.input('Article body', false)
+
+    await waitUntil(() => mockFormUpdater.calls.resolve === 5)
+
+    const submitButton = view.getByRole('button', { name: 'Create ticket' })
+    await waitUntil(() => !submitButton.hasAttribute('disabled'))
+
+    expect(submitButton).not.toBeDisabled()
+
+    await view.events.click(submitButton)
+
+    await waitUntil(() => mockTicket.calls.resolve)
+
+    await expect(view.findByRole('alert')).resolves.toHaveTextContent(
+      'Ticket has been created successfully.',
+    )
+
+    const router = getTestRouter()
+    expect(router.replace).toHaveBeenCalledWith('/tickets/1')
+  })
+
+  it('shows confirm popup, when leaving', async () => {
+    const { view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Foobar')
+
+    await getNode('ticket-create')?.settled
+
+    await view.events.click(view.getByRole('button', { name: 'Go back' }))
+
+    expect(view.queryByTestId('popupWindow')).toBeInTheDocument()
+
+    await expect(
+      view.findByRole('alert', { name: 'Confirm dialog' }),
+    ).resolves.toBeInTheDocument()
+  })
+
+  it('shows the CC field for type "Email"', async () => {
+    const { mockFormUpdater, view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Foobar')
+    await waitUntil(() => mockFormUpdater.calls.resolve)
+    await nextStep(view)
+
+    await view.events.click(view.getByLabelText('Send Email'))
+    await nextStep(view)
+
+    expect(view.getByLabelText('CC')).toBeInTheDocument()
+  })
+
+  // The rest of the test cases are covered by E2E test, due to limitations of JSDOM test environment.
+})
+
+describe('Creating new ticket as customer', () => {
+  beforeEach(() => {
+    mockPermissions(['ticket.customer'])
+    mockApplicationConfig({
+      customer_ticket_create: true,
+    })
+  })
+
+  it('shows 3 steps for customers', async () => {
+    const { view } = await visitTicketCreate()
+
+    const steps = ['1', '2', '3']
+    steps.forEach((step) => {
+      expect(view.getByRole('button', { name: step })).toBeInTheDocument()
+    })
+
+    expect(view.queryByRole('button', { name: '4' })).not.toBeInTheDocument()
+  })
+
+  it('redirects to the error page if ticket creation is turned off', async () => {
+    mockApplicationConfig({
+      customer_ticket_create: false,
+    })
+
+    const { view } = await visitTicketCreate()
+
+    expect(view.getByRole('main')).toHaveTextContent(
+      'Creating new tickets via web is disabled.',
+    )
+  })
+
+  it('does not show the organization field without secondary organizations', async () => {
+    mockAccount({
+      lastname: 'Doe',
+      firstname: 'John',
+      organization: defaultOrganization(),
+      hasSecondaryOrganizations: false,
+    })
+
+    const { mockFormUpdater, view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Foobar')
+
+    await waitUntil(() => mockFormUpdater.calls.resolve)
+
+    await nextStep(view)
+
+    expect(view.queryByLabelText('Organization')).not.toBeInTheDocument()
+  })
+
+  it('does show the organization field with secondary organizations', async () => {
+    mockAccount({
+      lastname: 'Doe',
+      firstname: 'John',
+      organization: defaultOrganization(),
+      hasSecondaryOrganizations: true,
+    })
+
+    const { mockFormUpdater, view } = await visitTicketCreate()
+
+    await view.events.type(view.getByLabelText('Title'), 'Foobar')
+
+    await waitUntil(() => mockFormUpdater.calls.resolve)
+
+    await nextStep(view)
+
+    expect(view.queryByLabelText('Organization')).toBeInTheDocument()
+  })
+})

+ 4 - 1
app/frontend/apps/mobile/pages/ticket/__tests__/ticket-information-update.spec.ts

@@ -11,6 +11,7 @@ import { mockPermissions } from '@tests/support/mock-permissions'
 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,
   userObjectAttributes,
@@ -115,6 +116,8 @@ describe('updating ticket information', () => {
 
     await view.events.type(view.getByLabelText('Ticket title'), '55')
 
+    await getNode('form-ticket-edit')?.settled
+
     const { mockUser } = mockUserGql()
     mockGraphQLSubscription(UserUpdatesDocument)
 
@@ -124,7 +127,7 @@ describe('updating ticket information', () => {
 
     await view.events.click(view.getByRole('link', { name: 'open 4' }))
 
-    expect(
+    await expect(
       view.findByRole('alert', { name: 'Confirm dialog' }),
     ).resolves.toBeInTheDocument()
   })

+ 6 - 2
app/frontend/apps/mobile/pages/ticket/types/tickets.ts

@@ -2,7 +2,11 @@
 
 import type { FormFieldValue } from '@shared/components/Form/types'
 import type { TicketCreateArticleType } from '@shared/entities/ticket/types'
-import type { TicketQuery, TicketArticlesQuery } from '@shared/graphql/types'
+import type {
+  TicketQuery,
+  TicketArticlesQuery,
+  UploadFileInput,
+} from '@shared/graphql/types'
 import type { ConfidentTake } from '@shared/types/utils'
 
 export type TicketById = TicketQuery['ticket']
@@ -23,7 +27,7 @@ export interface TicketFormData {
   customer_id?: number
   cc?: string[]
   body: string
-  // attachments?: UploadFile[] // TODO: field has currently no value handling implemented
+  attachments?: UploadFileInput[]
   group_id: number
   owner_id?: number
   state_id?: number

+ 304 - 143
app/frontend/apps/mobile/pages/ticket/views/TicketCreate.vue

@@ -1,7 +1,16 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, reactive } from 'vue'
+import {
+  computed,
+  nextTick,
+  onMounted,
+  onUnmounted,
+  reactive,
+  ref,
+  watch,
+} from 'vue'
+import { onBeforeRouteLeave, useRouter } from 'vue-router'
 import Form from '@shared/components/Form/Form.vue'
 import {
   EnumFormUpdaterId,
@@ -10,6 +19,7 @@ import {
 } from '@shared/graphql/types'
 import { useMultiStepForm, useForm } from '@shared/components/Form'
 import { useApplicationStore } from '@shared/stores/application'
+import { useTicketCreate } from '@shared/entities/ticket/composables/useTicketCreate'
 import { useTicketCreateArticleType } from '@shared/entities/ticket/composables/useTicketCreateArticleType'
 import { ButtonVariant } from '@shared/components/Form/fields/FieldButton/types'
 import { useTicketFormOganizationHandler } from '@shared/entities/ticket/composables/useTicketFormOrganizationHandler'
@@ -22,19 +32,28 @@ import {
   NotificationTypes,
   useNotifications,
 } from '@shared/components/CommonNotifications'
+import { useSessionStore } from '@shared/stores/session'
+import { ErrorStatusCodes } from '@shared/types/error'
+import type UserError from '@shared/errors/UserError'
 import { defineFormSchema } from '@mobile/form/defineFormSchema'
 import CommonStepper from '@mobile/components/CommonStepper/CommonStepper.vue'
 import CommonBackButton from '@mobile/components/CommonBackButton/CommonBackButton.vue'
 // No usage of "type" because of: https://github.com/typescript-eslint/typescript-eslint/issues/5468
+import { errorOptions } from '@mobile/router/error'
+import useConfirmation from '@mobile/components/CommonConfirmation/composable'
 import { TicketFormData } from '../types/tickets'
 import { useTicketCreateMutation } from '../graphql/mutations/create.api'
 
+const router = useRouter()
+
 // Add meta header with selected ticket create article type
 // TODO: add customer version or own view?
 // TODO: Signature handling?
 // TODO: Security options?
 // TODO: Discard changes handling
 
+const { form, node, isDirty, isValid, isDisabled, formSubmit } = useForm()
+
 const {
   multiStepPlugin,
   setMultiStep,
@@ -43,14 +62,22 @@ const {
   visitedSteps,
   stepNames,
   lastStepName,
-} = useMultiStepForm()
-
-const { form, isValid, isDisabled, formSubmit } = useForm()
+} = useMultiStepForm(node)
 
 const application = useApplicationStore()
 
+const onSubmit = () => {
+  setMultiStep()
+}
+
 const { ticketCreateArticleType, ticketArticleSenderTypeField } =
-  useTicketCreateArticleType()
+  useTicketCreateArticleType(onSubmit)
+
+const session = useSessionStore()
+
+const isCustomer = computed(() => {
+  return session.hasPermission('ticket.customer')
+})
 
 const getFormSchemaGroupSection = (
   stepName: string,
@@ -93,99 +120,106 @@ const getFormSchemaGroupSection = (
   }
 }
 
-const formSchema = defineFormSchema([
-  getFormSchemaGroupSection(
-    'ticketTitle',
-    __('Set a title for your ticket'),
-    [
-      {
-        name: 'title',
-        required: true,
-        object: EnumObjectManagerObjects.Ticket,
-        screen: 'create_top',
-        outerClass: '$reset flex grow items-center',
-        wrapperClass: '$reset',
-        labelClass: '$reset sr-only',
-        blockClass: '$reset',
-        innerClass: '$reset',
-        inputClass:
-          '$reset block bg-transparent border-b-[0.5px] border-white outline-none text-center text-xl placeholder:text-opacity-30', // placeholder: xyz...
-        props: {
-          placeholder: __('Title'),
-        },
+const ticketTitleSection = getFormSchemaGroupSection(
+  'ticketTitle',
+  __('Set a title for your ticket'),
+  [
+    {
+      name: 'title',
+      required: true,
+      object: EnumObjectManagerObjects.Ticket,
+      screen: 'create_top',
+      outerClass: '$reset w-full flex grow items-center',
+      wrapperClass: '$reset flex grow',
+      labelClass: '$reset sr-only',
+      blockClass: '$reset flex grow',
+      innerClass: '$reset flex grow px-8',
+      inputClass:
+        '$reset block grow bg-transparent border-b-[0.5px] border-white outline-none text-center text-xl placeholder:text-white placeholder:text-opacity-50',
+      props: {
+        placeholder: __('Title'),
+        onSubmit,
       },
-    ],
-    true,
-  ),
-  getFormSchemaGroupSection(
-    'ticketArticleType',
-    __('Select the type of ticket your are creating'),
-    [
-      {
-        ...ticketArticleSenderTypeField,
-        outerClass: 'grow flex items-center',
+    },
+  ],
+  true,
+)
+
+const ticketArticleTypeSection = getFormSchemaGroupSection(
+  'ticketArticleType',
+  __('Select the type of ticket your are creating'),
+  [
+    {
+      ...ticketArticleSenderTypeField,
+      outerClass: 'w-full flex grow items-center',
+      fieldsetClass: 'grow px-4',
+    },
+    {
+      if: '$existingAdditionalCreateNotes() && $getAdditionalCreateNote($values.articleSenderType) !== undefined',
+      isLayout: true,
+      element: 'p',
+      attrs: {
+        class: 'my-10 text-base text-center', // TODO: check size/styling
       },
-      {
-        if: '$existingAdditionalCreateNotes() && $getAdditionalCreateNote($values.articleSenderType) !== undefined',
-        isLayout: true,
-        element: 'p',
-        attrs: {
-          class: 'my-10 text-base text-center', // TODO: check size/styling
+      children: '$getAdditionalCreateNote($values.articleSenderType)',
+    },
+  ],
+  true,
+)
+
+const ticketMetaInformationSection = getFormSchemaGroupSection(
+  'ticketMetaInformation',
+  __('Additional information'),
+  [
+    {
+      isLayout: true,
+      component: 'FormGroup',
+      children: [
+        {
+          screen: 'create_top',
+          object: EnumObjectManagerObjects.Ticket,
         },
-        children: '$getAdditionalCreateNote($values.articleSenderType)',
-      },
-    ],
-    true,
-  ),
-  getFormSchemaGroupSection(
-    'ticketMetaInformation',
-    __('Additional information'),
-    [
-      {
-        isLayout: true,
-        component: 'FormGroup',
-        children: [
-          {
-            screen: 'create_top',
-            object: EnumObjectManagerObjects.Ticket,
-          },
-          // Because of the current field screen settings in the backend
-          // seed we need to add this manually.
-          {
-            if: '$values.articleSenderType === "email-out"',
-            name: 'cc',
-            label: __('CC'),
-            type: 'recipient',
-            props: {
-              multiple: true,
-              maxlength: 1000,
-            },
-          },
-        ],
-      },
-      {
-        isLayout: true,
-        component: 'FormGroup',
-        children: [
-          {
-            screen: 'create_middle',
-            object: EnumObjectManagerObjects.Ticket,
-          },
-        ],
-      },
-      {
-        isLayout: true,
-        component: 'FormGroup',
-        children: [
-          {
-            screen: 'create_bottom',
-            object: EnumObjectManagerObjects.Ticket,
+        // Because of the current field screen settings in the backend
+        // seed we need to add this manually.
+        {
+          if: '$values.articleSenderType === "email-out"',
+          name: 'cc',
+          label: __('CC'),
+          type: 'recipient',
+          props: {
+            multiple: true,
+            maxlength: 1000,
           },
-        ],
-      },
-    ],
-  ),
-  getFormSchemaGroupSection('ticketArticleMessage', __('Add a message'), [
+        },
+      ],
+    },
+    {
+      isLayout: true,
+      component: 'FormGroup',
+      children: [
+        {
+          screen: 'create_middle',
+          object: EnumObjectManagerObjects.Ticket,
+        },
+      ],
+    },
+    {
+      isLayout: true,
+      component: 'FormGroup',
+      children: [
+        {
+          screen: 'create_bottom',
+          object: EnumObjectManagerObjects.Ticket,
+        },
+      ],
+    },
+  ],
+)
+
+const ticketArticleMessageSection = getFormSchemaGroupSection(
+  'ticketArticleMessage',
+  __('Add a message'),
+  [
     {
       isLayout: true,
       component: 'FormGroup',
@@ -211,16 +245,38 @@ const formSchema = defineFormSchema([
         {
           type: 'file',
           name: 'attachments',
+          props: {
+            multiple: true,
+          },
         },
       ],
     },
-  ]),
-])
+  ],
+)
+
+const customerSchema = [
+  ticketTitleSection,
+  ticketMetaInformationSection,
+  ticketArticleMessageSection,
+]
+
+const agentSchema = [
+  ticketTitleSection,
+  ticketArticleTypeSection,
+  ticketMetaInformationSection,
+  ticketArticleMessageSection,
+]
+
+const formSchema = defineFormSchema(
+  isCustomer.value ? customerSchema : agentSchema,
+)
 
 const ticketCreateMutation = new MutationHandler(useTicketCreateMutation({}), {
   errorNotificationMessage: __('Ticket could not be created.'),
 })
 
+const isCreated = ref(false)
+
 const createTicket = async (formData: FormData<TicketFormData>) => {
   const { notify } = useNotifications()
 
@@ -230,31 +286,58 @@ const createTicket = async (formData: FormData<TicketFormData>) => {
   const { internalObjectAttributeValues, additionalObjectAttributeValues } =
     useObjectAttributeFormData(ticketObjectAttributesLookup.value, formData)
 
-  const result = await ticketCreateMutation.send({
-    input: {
-      ...internalObjectAttributeValues,
-      article: {
-        // TODO: "from" and "to" needs to be handled on server side
-        cc: formData.cc,
-        body: formData.body,
-        // attachments: {
-        //   files: formData.attachments,
-        //   formId: formData.formId,
-        // },
-        sender: ticketCreateArticleType[formData.articleSenderType].sender,
-        type: ticketCreateArticleType[formData.articleSenderType].type,
-        contentType: 'text/html',
-      },
-      objectAttributeValues: additionalObjectAttributeValues,
-    } as TicketCreateInput,
-  })
-
-  if (result) {
-    notify({
-      type: NotificationTypes.Success,
-      message: __('Ticket has been created successfully.'),
-    })
+  const input = {
+    ...internalObjectAttributeValues,
+    article: {
+      cc: formData.cc,
+      body: formData.body,
+      sender: isCustomer.value
+        ? 'Customer'
+        : ticketCreateArticleType[formData.articleSenderType].sender,
+      type: isCustomer.value
+        ? 'web'
+        : ticketCreateArticleType[formData.articleSenderType].type,
+      contentType: 'text/html',
+    },
+    objectAttributeValues: additionalObjectAttributeValues,
+  } as TicketCreateInput
+
+  if (formData.attachments && input.article) {
+    input.article.attachments = {
+      files: formData.attachments?.map((file) => ({
+        name: file.name,
+        type: file.type,
+      })),
+      formId: formData.formId,
+    }
   }
+
+  ticketCreateMutation
+    .send({ input })
+    .then((result) => {
+      if (result?.ticketCreate?.ticket) {
+        isCreated.value = true
+
+        notify({
+          type: NotificationTypes.Success,
+          message: __('Ticket has been created successfully.'),
+        })
+
+        // TODO: Add correct handling if permission field is implemented.
+        // if (result.ticketCreate?.ticket?.internalId && result.ticketCreate?.ticket?.policy?.show) {
+        if (result.ticketCreate?.ticket?.internalId) {
+          router.replace(`/tickets/${result.ticketCreate?.ticket?.internalId}`)
+        } else {
+          router.replace({ name: 'Home' })
+        }
+      }
+    })
+    .catch((errors: UserError) => {
+      notify({
+        message: errors.generalErrors[0],
+        type: NotificationTypes.Error,
+      })
+    })
 }
 
 const additionalCreateNotes = computed(
@@ -282,6 +365,77 @@ const submitButtonDisabled = computed(() => {
       visitedSteps.value.length < stepNames.value.length)
   )
 })
+
+const isScrolledToBottom = ref(true)
+
+const setIsScrolledToBottom = () => {
+  isScrolledToBottom.value =
+    window.innerHeight + document.documentElement.scrollTop >=
+    document.body.offsetHeight
+}
+
+watch(
+  () => activeStep.value,
+  () => {
+    nextTick(() => {
+      setIsScrolledToBottom()
+    })
+  },
+)
+
+onMounted(() => {
+  window.addEventListener('scroll', setIsScrolledToBottom)
+  window.addEventListener('resize', setIsScrolledToBottom)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('scroll', setIsScrolledToBottom)
+  window.removeEventListener('resize', setIsScrolledToBottom)
+})
+
+const { waitForConfirmation } = useConfirmation()
+
+onBeforeRouteLeave(async () => {
+  // TODO: Can we make this check a bit smarter?
+  //   Why is `isDirty` flag still true on submitted forms?
+  //   Why `isSubmitted` never goes true for multi-step forms?
+  if (!isDirty.value || isCreated.value) return true
+
+  const confirmed = await waitForConfirmation(
+    __('Are you sure? You have unsaved changes that will get lost.'),
+  )
+
+  return confirmed
+})
+</script>
+
+<script lang="ts">
+export default {
+  beforeRouteEnter(to, from, next) {
+    const { ticketCreateEnabled } = useTicketCreate()
+
+    if (!ticketCreateEnabled.value) {
+      errorOptions.value = {
+        title: __('Forbidden'),
+        message: __('Creating new tickets via web is disabled.'),
+        statusCode: ErrorStatusCodes.Forbidden,
+        route: to.fullPath,
+      }
+
+      next({
+        name: 'Error',
+        query: {
+          redirect: '1',
+        },
+        replace: true,
+      })
+
+      return
+    }
+
+    next()
+  },
+}
 </script>
 
 <template>
@@ -302,16 +456,14 @@ const submitButtonDisabled = computed(() => {
           input-class="flex justify-center items-center w-9 h-9 rounded-full text-black text-center formkit-variant-primary:bg-yellow"
           type="button"
           :disabled="submitButtonDisabled"
+          :title="$t('Create ticket')"
           @click="formSubmit"
-          ><CommonIcon
-            :aria-label="__('Create ticket')"
-            name="mobile-arrow-up"
-            size="base"
+          ><CommonIcon name="mobile-arrow-up" size="base" decorative
         /></FormKit>
       </div>
     </div>
   </header>
-  <div class="flex h-full flex-col px-4">
+  <div class="flex h-full flex-col px-4 pb-36">
     <Form
       id="ticket-create"
       ref="form"
@@ -321,25 +473,34 @@ const submitButtonDisabled = computed(() => {
       :multi-step-form-groups="Object.keys(allSteps)"
       :schema-data="schemaData"
       :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
+      :autofocus="true"
       use-object-attributes
       @submit="createTicket($event as FormData<TicketFormData>)"
-    >
-      <template #after-fields>
-        <FormKit
-          type="button"
-          :outer-class="`mt-8 mb-6 ${
-            lastStepName === activeStep ? 'invisible' : ''
-          }`"
-          :aria-hidden="lastStepName === activeStep"
-          wrapper-class="flex grow justify-center items-center"
-          input-class="py-2 px-4 w-full h-14 text-xl font-semibold rounded-xl select-none"
-          :variant="ButtonVariant.Primary"
-          @click="setMultiStep()"
-        >
-          {{ $t('Continue') }}
-        </FormKit>
-      </template>
-    </Form>
-    <CommonStepper v-model="activeStep" :steps="allSteps" class="mb-8 px-8" />
+    />
   </div>
+  <footer
+    :class="{
+      'h-32': lastStepName !== activeStep,
+      'h-14': lastStepName === activeStep,
+      'bg-gray-light backdrop-blur-lg': !isScrolledToBottom,
+    }"
+    class="bottom-navigation fixed bottom-0 z-10 w-full px-4 transition"
+  >
+    <FormKit
+      v-if="lastStepName !== activeStep"
+      :variant="ButtonVariant.Primary"
+      type="button"
+      outer-class="mt-4 mb-2"
+      wrapper-class="flex grow justify-center items-center"
+      input-class="py-2 px-4 w-full h-14 text-xl font-semibold rounded-xl select-none"
+      @click="setMultiStep()"
+    >
+      {{ $t('Continue') }}
+    </FormKit>
+    <CommonStepper
+      v-model="activeStep"
+      :steps="allSteps"
+      class="mt-4 mb-8 px-8"
+    />
+  </footer>
 </template>

+ 0 - 1
app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue

@@ -101,7 +101,6 @@ const { waitForConfirmation } = useConfirmation()
 onBeforeRouteLeave(async () => {
   if (!isDirty.value) return true
 
-  // TODO store state in global storage instead of this
   const confirmed = await waitForConfirmation(
     __('Are you sure? You have unsaved changes that will get lost.'),
   )

BIN
app/frontend/cypress/shared/components/Form/fields/FieldRadio/__image_snapshots__/renders as buttons #0.png


BIN
app/frontend/cypress/shared/components/Form/fields/FieldRadio/__image_snapshots__/renders as buttons - checked #0.png


BIN
app/frontend/cypress/shared/components/Form/fields/FieldRadio/__image_snapshots__/renders as disabled buttons #0.png


+ 16 - 12
app/frontend/shared/components/Form/Form.vue

@@ -167,6 +167,15 @@ const changeFields = toRef(props, 'changeFields')
 
 const updaterChangedFields = new Set<string>()
 
+const autofocusFirstInput = () => {
+  nextTick(() => {
+    const firstInput = getFirstFocusableElement(formElement.value)
+
+    firstInput?.focus()
+    firstInput?.scrollIntoView({ block: 'nearest' })
+  })
+}
+
 const setFormNode = (node: FormKitNode) => {
   formNode.value = node
 
@@ -186,16 +195,11 @@ const setFormNode = (node: FormKitNode) => {
     testFlags.set(`${formName}.settled`)
     emit('settled')
 
-    if (props.autofocus) {
-      nextTick(() => {
-        const firstInput = getFirstFocusableElement(formElement.value)
-
-        firstInput?.focus()
-        firstInput?.scrollIntoView({ block: 'nearest' })
-      })
-    }
+    if (props.autofocus) autofocusFirstInput()
   })
 
+  node.on('autofocus', autofocusFirstInput)
+
   emit('node', node)
 }
 
@@ -818,17 +822,17 @@ const initializeFormSchema = () => {
       ),
     )
 
-    formUpdaterQueryHandler.watchOnResult((queryResult) => {
+    formUpdaterQueryHandler.onResult((queryResult) => {
       // Execute the form handler function so that they can manipulate the form updater result.
       if (!formSchemaInitialized.value) {
         executeFormHandler(FormHandlerExecution.Initial, localInitialValues)
       }
 
-      if (queryResult?.formUpdater) {
+      if (queryResult?.data.formUpdater) {
         updateChangedFields(
           changeFields.value
-            ? merge(queryResult.formUpdater, changeFields.value)
-            : queryResult.formUpdater,
+            ? merge(queryResult.data.formUpdater, changeFields.value)
+            : queryResult.data.formUpdater,
         )
       }
 

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