123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import { useLocalStorage } from '@vueuse/core'
- import {
- computed,
- toRef,
- provide,
- Teleport,
- markRaw,
- type Component,
- reactive,
- nextTick,
- watch,
- ref,
- } from 'vue'
- import {
- NotificationTypes,
- useNotifications,
- } from '#shared/components/CommonNotifications/index.ts'
- import Form from '#shared/components/Form/Form.vue'
- import type { FormSubmitData } from '#shared/components/Form/types.ts'
- import { useForm } from '#shared/components/Form/useForm.ts'
- import { setErrors } from '#shared/components/Form/utils.ts'
- import { useConfirmation } from '#shared/composables/useConfirmation.ts'
- import { useTicketArticleReplyAction } from '#shared/entities/ticket/composables/useTicketArticleReplyAction.ts'
- import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
- import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
- import type { TicketFormData } from '#shared/entities/ticket/types.ts'
- import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
- import {
- useArticleDataHandler,
- type AddArticleCallbackArgs,
- } from '#shared/entities/ticket-article/composables/useArticleDataHandler.ts'
- import UserError from '#shared/errors/UserError.ts'
- import { EnumTaskbarEntity, EnumFormUpdaterId } from '#shared/graphql/types.ts'
- import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
- import { useSessionStore } from '#shared/stores/session.ts'
- import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
- import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
- import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
- import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
- import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
- import ArticleList from '../components/TicketDetailView/ArticleList.vue'
- import ArticleReply from '../components/TicketDetailView/ArticleReply.vue'
- import TicketDetailTopBar from '../components/TicketDetailView/TicketDetailTopBar/TicketDetailTopBar.vue'
- import TicketSidebar from '../components/TicketSidebar.vue'
- import { ARTICLES_INFORMATION_KEY } from '../composables/useArticleContext.ts'
- import { useTicketArticleReply } from '../composables/useTicketArticleReply.ts'
- import {
- initializeTicketInformation,
- provideTicketInformation,
- } from '../composables/useTicketInformation.ts'
- import {
- useTicketSidebar,
- useProvideTicketSidebar,
- } from '../composables/useTicketSidebar.ts'
- import {
- type TicketSidebarContext,
- TicketSidebarScreenType,
- } from '../types/sidebar.ts'
- interface Props {
- internalId: string
- }
- const props = defineProps<Props>()
- const { ticket, ticketId, canUpdateTicket, ...ticketInformation } =
- initializeTicketInformation(toRef(props, 'internalId'))
- const onAddArticleCallback = ({ articlesQuery }: AddArticleCallbackArgs) => {
- return (articlesQuery as QueryHandler).refetch()
- }
- const { articleResult, articlesQuery, isLoadingArticles } =
- useArticleDataHandler(ticketId, { pageSize: 20, onAddArticleCallback })
- provide(ARTICLES_INFORMATION_KEY, {
- articles: computed(() => articleResult.value),
- articlesQuery,
- })
- const {
- form,
- flags,
- isDisabled,
- isDirty,
- formNodeId,
- formReset,
- formSubmit,
- triggerFormUpdater,
- } = useForm()
- const {
- activeTaskbarTab,
- activeTaskbarTabFormId,
- activeTaskbarTabNewArticlePresent,
- } = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
- const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(triggerFormUpdater)
- const sidebarContext = computed<TicketSidebarContext>(() => ({
- screenType: TicketSidebarScreenType.TicketDetailView,
- form: form.value,
- formValues: {
- // TODO: Workaround, to make the sidebars working for now.
- customer_id: ticket.value?.customer.internalId,
- organization_id: ticket.value?.organization?.internalId,
- },
- }))
- useProvideTicketSidebar(sidebarContext)
- const { hasSidebar, activeSidebar, switchSidebar } = useTicketSidebar()
- const {
- ticketSchema,
- articleSchema,
- currentArticleType,
- ticketArticleTypes,
- securityIntegration,
- isTicketCustomer,
- articleTypeHandler,
- articleTypeSelectHandler,
- } = useTicketEditForm(ticket, form)
- const formEditAttributeLocation = computed(() => {
- if (activeSidebar.value === 'information') return '#ticketEditAttributeForm'
- return '#wrapper-form-ticket-edit'
- })
- const {
- isArticleFormGroupValid,
- newTicketArticlePresent,
- showTicketArticleReplyForm,
- } = useTicketArticleReply(form, activeTaskbarTabNewArticlePresent)
- provideTicketInformation({
- ticket,
- ticketId,
- canUpdateTicket,
- form,
- newTicketArticlePresent,
- showTicketArticleReplyForm,
- ...ticketInformation,
- })
- const ticketEditSchemaData = reactive({
- formEditAttributeLocation,
- securityIntegration,
- newTicketArticlePresent,
- currentArticleType,
- })
- const ticketEditSchema = [
- {
- isLayout: true,
- component: 'Teleport',
- props: {
- to: '$formEditAttributeLocation',
- },
- children: [
- {
- isLayout: true,
- component: 'FormGroup',
- props: {
- class: '@container/form-group',
- showDirtyMark: true,
- },
- children: [ticketSchema],
- },
- ],
- },
- {
- if: '$newTicketArticlePresent',
- isLayout: true,
- component: 'Teleport',
- props: {
- to: '#ticketArticleReplyForm',
- },
- children: [
- {
- isLayout: true,
- component: 'FormGroup',
- props: {
- class: '@container/form-group',
- },
- children: [articleSchema],
- },
- ],
- },
- ]
- const { waitForConfirmation, waitForVariantConfirmation } = useConfirmation()
- const discardChanges = async () => {
- const confirm = await waitForVariantConfirmation('unsaved')
- if (confirm) {
- newTicketArticlePresent.value = false
- nextTick(() => {
- formReset()
- })
- }
- }
- const { isTicketFormGroupValid, initialTicketValue, editTicket } =
- useTicketEdit(ticket, form)
- const { openReplyForm } = useTicketArticleReplyAction(
- form,
- showTicketArticleReplyForm,
- )
- const isFormValid = computed(() => {
- if (!newTicketArticlePresent.value) return isTicketFormGroupValid.value
- return isTicketFormGroupValid.value && isArticleFormGroupValid.value
- })
- const formAdditionalRouteQueryParams = computed(() => ({
- taskbarId: activeTaskbarTab.value?.taskbarTabId,
- }))
- const { notify } = useNotifications()
- const checkSubmitEditTicket = () => {
- if (!isFormValid.value) {
- if (activeSidebar.value !== 'information') switchSidebar('information')
- if (newTicketArticlePresent.value && !isArticleFormGroupValid.value) {
- document
- .querySelector('#ticketArticleReplyForm')
- ?.scrollIntoView({ behavior: 'smooth' })
- }
- }
- formSubmit()
- }
- const skipValidator = ref<string>()
- const handleIncompleteChecklist = async (validator: string) => {
- const confirmed = await waitForConfirmation(
- __(
- 'You have unchecked items in the checklist. Do you want to handle them before closing this ticket?',
- ),
- {
- headerTitle: __('Incomplete Ticket Checklist'),
- headerIcon: 'checklist',
- buttonLabel: __('Yes, open the checklist'),
- cancelLabel: __('No, just close the ticket'),
- },
- )
- if (confirmed) {
- if (activeSidebar.value !== 'checklist') switchSidebar('checklist')
- return false
- }
- if (confirmed === false) {
- skipValidator.value = validator
- formSubmit()
- return true
- }
- return false
- }
- const handleUserErrorException = (exception: string) => {
- if (
- exception ===
- 'Service::Ticket::Update::Validator::ChecklistCompleted::IncompleteChecklistError'
- )
- return handleIncompleteChecklist(exception)
- }
- const submitEditTicket = async (formData: FormSubmitData<TicketFormData>) => {
- const updateFormData = currentArticleType.value?.updateForm
- if (updateFormData) {
- formData = updateFormData(formData)
- }
- return editTicket(formData, skipValidator.value)
- .then((result) => {
- if (result?.ticketUpdate?.ticket) {
- notify({
- id: 'ticket-update',
- type: NotificationTypes.Success,
- message: __('Ticket updated successfully.'),
- })
- newTicketArticlePresent.value = false
- return true // will reset the ticket form, because of the reset inside the Form component
- }
- return false
- })
- .catch((error) => {
- if (error instanceof UserError) {
- const exception = error.getFirstErrorException()
- if (exception) return handleUserErrorException(exception)
- if (form.value?.formNode) {
- setErrors(form.value.formNode, error)
- return
- }
- }
- notify({
- id: 'ticket-update-failed',
- type: NotificationTypes.Error,
- message: __('Ticket update failed.'),
- })
- })
- .finally(() => {
- skipValidator.value = undefined
- })
- }
- const handleShowArticleForm = (
- articleType: string,
- performReply: AppSpecificTicketArticleType['performReply'],
- ) => {
- openReplyForm({ articleType, ...performReply?.(ticket.value) })
- }
- const onEditFormSettled = () => {
- watch(
- () => flags.value.newArticlePresent,
- (newValue) => {
- newTicketArticlePresent.value = newValue
- },
- )
- }
- // Reset newTicketArticlePresent when ticket changed, that the
- // taskbar information is used for the start.
- watch(ticketId, () => {
- newTicketArticlePresent.value = undefined
- })
- const { userId } = useSessionStore()
- const articleReplyPinned = useLocalStorage(
- `${userId}-article-reply-pinned`,
- false,
- )
- </script>
- <template>
- <LayoutContent
- name="ticket-detail"
- no-padding
- background-variant="primary"
- :show-sidebar="hasSidebar"
- content-alignment="center"
- >
- <CommonLoader class="mt-8" :loading="!ticket">
- <div
- class="grid h-full w-full"
- :class="{
- 'grid-rows-[max-content_max-content_max-content]':
- !newTicketArticlePresent || !articleReplyPinned,
- 'grid-rows-[max-content_1fr_max-content]':
- newTicketArticlePresent && articleReplyPinned,
- }"
- >
- <TicketDetailTopBar />
- <ArticleList :aria-busy="isLoadingArticles" />
- <ArticleReply
- v-if="ticket?.id"
- :ticket="ticket"
- :new-article-present="newTicketArticlePresent"
- :create-article-type="ticket.createArticleType?.name"
- :ticket-article-types="ticketArticleTypes"
- :is-ticket-customer="isTicketCustomer"
- @show-article-form="handleShowArticleForm"
- />
- <div id="wrapper-form-ticket-edit" class="hidden" aria-hidden="true">
- <Form
- v-if="ticket?.id && initialTicketValue"
- id="form-ticket-edit"
- :key="ticketId"
- ref="form"
- :form-id="activeTaskbarTabFormId"
- :schema="ticketEditSchema"
- :flatten-form-groups="['ticket']"
- :handlers="[articleTypeHandler()]"
- :form-kit-plugins="[articleTypeSelectHandler]"
- :schema-data="ticketEditSchemaData"
- :initial-values="initialTicketValue"
- :initial-entity-object="ticket"
- :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketEdit"
- :form-updater-additional-params="formAdditionalRouteQueryParams"
- use-object-attributes
- :schema-component-library="{
- Teleport: markRaw(Teleport) as unknown as Component,
- }"
- @submit="submitEditTicket($event as FormSubmitData<TicketFormData>)"
- @settled="onEditFormSettled"
- @changed="setSkipNextStateUpdate(true)"
- />
- </div>
- </div>
- </CommonLoader>
- <template #sideBar="{ isCollapsed, toggleCollapse }">
- <TicketSidebar
- :is-collapsed="isCollapsed"
- :toggle-collapse="toggleCollapse"
- :context="sidebarContext"
- />
- </template>
- <template #bottomBar>
- <CommonButton
- v-if="isDirty"
- size="large"
- variant="danger"
- :disabled="isDisabled"
- @click="discardChanges"
- >{{ __('Discard your unsaved changes') }}</CommonButton
- >
- <CommonButton
- size="large"
- variant="submit"
- type="button"
- :form="formNodeId"
- :disabled="isDisabled"
- @click="checkSubmitEditTicket"
- >{{ __('Update') }}</CommonButton
- >
- </template>
- </LayoutContent>
- </template>
|