Maintenance: Improve Checklist handling

This commit is contained in:
Mantas Masalskis 2024-10-03 17:34:05 +02:00
parent 29b5f4523a
commit 12e7524390
89 changed files with 823 additions and 792 deletions

View file

@ -42,6 +42,13 @@ class ChecklistTemplate extends App.ControllerSubContent
@html elLocal
value = @checklistSetting.prop('checked')
checklistTemplatesTable = elLocal.find('.js-checklistTemplatesTable')
if value is true
checklistTemplatesTable.show()
else
checklistTemplatesTable.hide()
validateOnSubmit: (params) ->
errors = {}
if !params.items || params.items.length is 0

View file

@ -974,17 +974,16 @@ class App.TicketZoom extends App.Controller
isPendingClose = ticketState.state_type.name is 'pending action' && App.TicketState.find(ticketState.next_state_id).state_type.name is 'closed'
return @submitTimeAccounting(e, ticket, macro, editContollerForm) if !isClosed && !isPendingClose
App.Checklist.completedForTicketId(ticket.id, (data) =>
return @submitTimeAccounting(e, ticket, macro, editContollerForm) if !data || data.completed is null || data.completed
if !ticket.checklist_incomplete
return @submitTimeAccounting(e, ticket, macro, editContollerForm)
new App.TicketZoomChecklistModal(
container: @el.closest('.content')
ticket: ticket
cancelCallback: =>
@submitEnable(e)
submitCallback: =>
@submitTimeAccounting(e, ticket, macro, editContollerForm)
)
new App.TicketZoomChecklistModal(
container: @el.closest('.content')
ticket: ticket
cancelCallback: =>
@submitEnable(e)
submitCallback: =>
@submitTimeAccounting(e, ticket, macro, editContollerForm)
)
submitTimeAccounting: (e, ticket, macro, editContollerForm) =>

View file

@ -9,42 +9,16 @@ class App.TicketZoomMeta extends App.ControllerObserver
observe:
number: true
created_at: true
updated_at: true
escalation_at: true
constructor: ->
super
App.ChecklistItem.subscribe(@checklistItemsChanged)
@subscribeToChecklistTickets()
checklistItemsChanged: =>
@subscribeToChecklistTickets()
@forceRerender()
checklistTicketChanged: =>
@forceRerender()
forceRerender: =>
@render(App[@model].fullLocal(@object_id))
subscribeToChecklistTickets: =>
if @checklistTicketsSubscriptions
for id, key in @checklistTicketsSubscriptions
App.Ticket.unsubscribeItem(id, key)
@checklistTicketsSubscriptions = undefined
checklist = App.Checklist.findByAttribute('ticket_id', @object_id)
return if !checklist
@checklistTicketsSubscriptions = checklist
.sorted_items()
.filter (elem) -> elem.ticket_id
.map (elem) => [elem.ticket_id, App.Ticket.subscribeItem(elem.ticket_id, @checklistTicketChanged)]
checklist_total: true
checklist_incomplete: true
render: (ticket) =>
checklistState = App.Checklist.calculateState(ticket)
checklistReferences = App.Checklist.calculateReferences(ticket)
if App.Config.get('checklist')
checklistState = App.Checklist.calculateState(ticket)
checklistReferences = App.Checklist.calculateReferences(ticket)
@html App.view('ticket_zoom/meta')(
ticket: ticket

View file

@ -64,9 +64,10 @@ class SidebarChecklist extends App.Controller
# ticket subscriptions
sid = App.Ticket.subscribeItem(
@ticket.id,
=>
(ticket) =>
@delay =>
@badgeRenderLocal()
return if ticket.updated_by_id is App.Session.get().id
@shown() if !@widget?.actionController
)
@ -76,6 +77,10 @@ class SidebarChecklist extends App.Controller
sid: sid,
)
# Exit early and subscribe only to the ticket when sidebar is not opened
return if !(@widget instanceof App.SidebarChecklistShow)
# Keep going and subscribe to checklist items and tickets in there when sidebar is opened
# checklist subscriptions
checklist = App.Checklist.findByAttribute('ticket_id', @ticket.id)
return if !checklist
@ -126,9 +131,9 @@ class SidebarChecklist extends App.Controller
@startLoading()
@ajax(
id: 'checklist_ticket'
id: "checklist_ticket#{@ticket.id}"
type: 'GET'
url: "#{@apiPath}/tickets/#{@ticket.id}/checklist"
url: "#{@apiPath}/checklists/by_ticket/#{@ticket.id}"
processData: true
success: (data, status, xhr) =>
@clearWidget()
@ -140,11 +145,11 @@ class SidebarChecklist extends App.Controller
@checklist = App.Checklist.find(data.id)
@widget = new App.SidebarChecklistShow(el: @elSidebar, parentVC: @, checklist: @checklist, readOnly: !@changeable, enterEditMode: enterEditMode)
@subscribe()
else
@widget = new App.SidebarChecklistStart(el: @elSidebar, parentVC: @, readOnly: !@changeable)
@subscribe()
@renderActions()
@badgeRenderLocal()
)
@ -161,7 +166,7 @@ class SidebarChecklist extends App.Controller
name: 'checklist'
icon: 'checklist'
counterPossible: true
counter: App.Checklist.findByAttribute('ticket_id', @ticket.id)?.open_items().length
counter: @ticket.checklist_incomplete
}
badgeRender: (el) =>

View file

@ -59,6 +59,7 @@ class App.SidebarChecklistShow extends App.Controller
item.checklist_id = @checklist.id
item.text = ''
item.save(
done: ->
App.ChecklistItem.full(@id, callbackDone, force: true)
@ -235,8 +236,6 @@ class App.SidebarChecklistShow extends App.Controller
sorted_items = @checklist.sorted_items()
for object in sorted_items
ticket = undefined
ticketAccess = undefined
if object.ticket_id
ticket = App.Ticket.find(object.ticket_id)
ticketAccess = if ticket then ticket.userGroupAccess('read') else false
@ -272,6 +271,7 @@ class App.SidebarChecklistShow extends App.Controller
if @enterEditModeId
cell = @table.find("tbody tr[data-id='" + @enterEditModeId + "']").find('.checklistItemValue')[0]
row = $(cell).closest('tr')
return if !row.length
@enterEditModeId = undefined
@activateItemEditMode(cell, row, row.data('id'))
@ -283,6 +283,7 @@ class ChecklistItemEdit extends App.Controller
events:
'click .js-cancel': 'onCancel'
'click .js-confirm': 'onConfirm'
'blur .js-input': 'onConfirm'
'keyup #checklistItemEditText': 'onKeyUp'
constructor: ->

View file

@ -33,7 +33,8 @@ class App.SidebarChecklistStart extends App.Controller
@ajax(
id: 'checklist_ticket_add_empty'
type: 'POST'
url: "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist"
url: "#{@apiPath}/checklists"
data: JSON.stringify({ ticket_id: @parentVC.ticket.id })
processData: true
success: (data, status, xhr) =>
@parentVC.shown(true)
@ -52,7 +53,7 @@ class App.SidebarChecklistStart extends App.Controller
@ajax(
id: 'checklist_ticket_add_from_template'
type: 'POST'
url: "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist"
url: "#{@apiPath}/checklists"
data: JSON.stringify({ ticket_id: @parentVC.ticket.id, template_id: params.checklist_template_id })
success: (data, status, xhr) =>
@parentVC.shown()

View file

@ -5,7 +5,7 @@ class TicketReferences extends App.PopoverProvider
@includeData = false
buildTitleFor: (elem) ->
App.i18n.translateInline('Tracked by checklist item by')
App.i18n.translateInline('Tracked as checklist item in')
buildContentFor: (elem) ->
@buildHtmlContent(

View file

@ -38,26 +38,17 @@ class App.Checklist extends App.Model
)
@calculateState: (ticket) ->
checklist = App.Checklist.findByAttribute('ticket_id', ticket.id)
return if !checklist
all = checklist.sorted_items().length
open = checklist.open_items().length
return undefined if !open
return if !ticket.checklist_incomplete
{
all: all,
open: open
all: ticket.checklist_total
open: ticket.checklist_incomplete
}
@calculateReferences: (ticket) ->
items = App.ChecklistItem
.findAllByAttribute('ticket_id', ticket.id)
checklists = App.Checklist
.findAll(ticket.referencing_checklist_ids)
.filter (elem) -> !elem.ticket_inaccessible
checklist_ids = _.unique items.map (elem) -> elem.checklist_id
checklists = App.Checklist.findAll checklist_ids
App.Ticket.findAll checklists.map (elem) -> elem.ticket_id

View file

@ -1,7 +1,5 @@
class App.ChecklistTemplateItem extends App.Model
@configure 'ChecklistTemplateItem', 'text', 'checklist_template_id', 'updated_at'
@extend Spine.Model.Ajax
@url: @apiPath + '/checklist_template_items'
@configure_attributes = [
{ name: 'text', display: __('Name'), tag: 'input', type: 'text', limit: 100, null: false, parentClass: 'checklistItemNameCell' },

View file

@ -1,5 +1,9 @@
<div class="page-header">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-checklistSetting">
<input name="enableChecklists" type="checkbox" id="enableChecklists" <% if @C('checklist'): %>checked<% end %>>
<label for="enableChecklists"></label>
</div>
<h1><%- @T('Checklists') %><small></small></h1>
</div>
</div>
@ -7,17 +11,6 @@
<div class="page-content">
<p><%- @T('With checklists you can keep track of the progress of your ticket related tasks.') %></p>
<div class="settings-entry">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-checklistSetting">
<input name="enableChecklists" type="checkbox" id="enableChecklists" <% if @C('checklist'): %>checked<% end %>>
<label for="enableChecklists"></label>
</div>
<h2><%- @T('Enable Checklists') %></h2>
</div>
<p><%- @T('Allow users to add new checklists.') %></p>
</div>
<div class="settings-entry settings-entry--stretched vertical js-checklistTemplatesTable">
</div>
</div>

View file

@ -3,23 +3,29 @@
class ChecklistItemsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def index
model_index_render(Checklist::Item.for_user(current_user), params)
end
def show
model_show_render(Checklist::Item.for_user(current_user), params)
model_show_render(Checklist::Item, existing_item_params)
end
def create
model_create_render(Checklist::Item.for_user(current_user), params)
model_create_render(Checklist::Item, new_item_params)
end
def update
model_update_render(Checklist::Item.for_user(current_user), params)
model_update_render(Checklist::Item, existing_item_params)
end
def destroy
model_destroy_render(Checklist::Item.for_user(current_user), params)
model_destroy_render(Checklist::Item, existing_item_params)
end
private
def new_item_params
params.permit(:text, :checklist_id)
end
def existing_item_params
params.permit(:text, :id, :checked)
end
end

View file

@ -1,25 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class ChecklistTemplateItemsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def index
model_index_render(ChecklistTemplate::Item, params)
end
def show
model_show_render(ChecklistTemplate::Item, params)
end
def create
model_create_render(ChecklistTemplate::Item, params)
end
def update
model_update_render(ChecklistTemplate::Item, params)
end
def destroy
model_destroy_render(ChecklistTemplate::Item, params)
end
end

View file

@ -3,23 +3,52 @@
class ChecklistsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def index
model_index_render(Checklist.for_user(current_user), params)
def show_by_ticket
checklist = Checklist.find_by ticket_id: params[:ticket_id]
if checklist
authorize!(checklist, :show?)
assets = ApplicationModel::CanAssets.reduce([checklist] + checklist.items, {})
render json: { id: checklist.id, assets: assets }
return
end
render json: {}
end
def show
model_show_render(Checklist.for_user(current_user), params)
model_show_render(Checklist, existing_checklist_params)
end
def create
model_create_render(Checklist.for_user(current_user), params)
new_checklist = if params[:template_id].present?
ChecklistTemplate.find_by(id: params[:template_id]).create_from_template!(ticket_id: params[:ticket_id])
else
Checklist.create!(name: '', ticket_id: params[:ticket_id]).tap do |checklist|
Checklist::Item.create!(checklist_id: checklist.id, text: '')
end
end
new_checklist.reload
render json: { id: new_checklist.id, assets: new_checklist.assets({}) }, status: :created
end
def update
model_update_render(Checklist.for_user(current_user), params)
model_update_render(Checklist, existing_checklist_params)
end
def destroy
model_destroy_render(Checklist.for_user(current_user), params)
model_destroy_render(Checklist, existing_checklist_params)
end
private
def new_checklist_params
params.permit(:ticket_id, :name)
end
def existing_checklist_params
params.permit(:id, :name, sorted_item_ids: [])
end
end

View file

@ -1,52 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class TicketChecklistController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def show
if checklist
render json: { id: checklist.id, assets: checklist.assets({}) }
return
end
render json: {}
end
def create
new_checklist = if params[:template_id].present?
ChecklistTemplate.find_by(id: params[:template_id]).create_from_template!(ticket_id: params[:ticket_id])
else
Checklist.create!(name: '', ticket_id: params[:ticket_id]).tap do |checklist|
Checklist::Item.create!(checklist_id: checklist.id, text: '')
end
end
new_checklist.reload
render json: { id: new_checklist.id, assets: new_checklist.assets({}) }
end
def update
checklist.update! params.permit(:name, sorted_item_ids: [])
render json: { id: checklist.id, assets: checklist.assets({}) }
end
def destroy
checklist.destroy!
render json: { success: true }
end
def completed
render json: {
completed: checklist&.completed?
}
end
private
def checklist
@checklist ||= Checklist.find_by(ticket: params[:ticket_id])
end
end

View file

@ -1,37 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class TicketChecklistItemsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def create
new_item = checklist.items.create!(checklist_params)
render json: { id: new_item.id, assets: checklist.assets({}) }
end
def update
checklist_item.update!(checklist_params)
render json: { success: true }
end
def destroy
checklist_item.destroy!
render json: { success: true }
end
private
def checklist
@checklist ||= Checklist.find_by!(ticket: params[:ticket_id])
end
def checklist_item
@checklist_item ||= checklist.items.find(params[:id])
end
def checklist_params
params.permit(:text, :checked)
end
end

View file

@ -120,7 +120,7 @@ const submitEdit = () => {
const submitEditResult = props.onSubmitEdit(inputValue.value)
if (submitEditResult instanceof Promise) {
if (submitEditResult instanceof Promise)
return submitEditResult
.then((result) => {
result?.()
@ -128,7 +128,6 @@ const submitEdit = () => {
stopEditing(false)
})
.catch(() => {})
}
submitEditResult?.()
@ -150,7 +149,10 @@ const handleMouseLeave = () => {
isHoverTargetLink.value = false
}
onClickOutside(target, () => stopEditing())
onClickOutside(target, () => {
if (isEditing.value) return submitEdit()
stopEditing()
})
const { setupLinksHandlers } = useHtmlLinks('/desktop')

View file

@ -1,6 +1,6 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
import { waitFor } from '@testing-library/vue'
import { fireEvent, waitFor } from '@testing-library/vue'
import { renderComponent } from '#tests/support/components/index.ts'
@ -88,6 +88,28 @@ describe('CommonInlineEdit', async () => {
expect(submitEditCallbackSpy).toHaveBeenCalledWith('test value update 2')
})
it('submits on background click', async () => {
const submitEditCallbackSpy = vi.fn()
const wrapper = renderInlineEdit({
onSubmitEdit: (value: string) => submitEditCallbackSpy(value),
})
await wrapper.events.click(wrapper.getByRole('button'))
await wrapper.events.type(wrapper.getByRole('textbox'), ' update 2')
await waitFor(() =>
expect(
wrapper.getByRole('textbox', { name: 'Inline Edit Label' }),
).toBeInTheDocument(),
)
await fireEvent.click(document.body)
expect(submitEditCallbackSpy).toHaveBeenCalledWith('test value update 2')
})
it('do not stop edit mode when submit promise failed', async () => {
const wrapper = renderInlineEdit({
onSubmitEdit: (): Promise<void> => {

View file

@ -15,6 +15,7 @@ import {
} from '#shared/entities/ticket/graphql/mutations/update.mocks.ts'
import { mockTicketArticlesQuery } from '#shared/entities/ticket/graphql/queries/ticket/articles.mocks.ts'
import { mockTicketQuery } from '#shared/entities/ticket/graphql/queries/ticket.mocks.ts'
import { getTicketUpdatesSubscriptionHandler } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.mocks.ts'
import { createDummyArticle } from '#shared/entities/ticket-article/__tests__/mocks/ticket-articles.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
import { EnumUserErrorException } from '#shared/graphql/types.ts'
@ -134,7 +135,15 @@ describe('Ticket detail view', () => {
it('updates incomplete checklist item count', async () => {
mockTicketQuery({
ticket: createDummyTicket(),
ticket: createDummyTicket({
checklist: {
id: convertToGraphQLId('Checklist', 1),
complete: 1,
completed: false,
total: 2,
incomplete: 1,
},
}),
})
const testArticle = createDummyArticle({
@ -196,6 +205,16 @@ describe('Ticket detail view', () => {
},
})
await getTicketUpdatesSubscriptionHandler().trigger({
ticketUpdates: {
ticket: {
checklist: {
incomplete: 0,
},
},
},
})
expect(
view.queryByRole('status', { name: 'Incomplete checklist items' }),
).not.toBeInTheDocument()

View file

@ -51,8 +51,8 @@ const menuItemKeys = computed(() =>
ref="popoverTarget"
v-tooltip="
referencingTicketsCount === 1
? $t('Show tracked ticket')
: $t('Show tracked tickets')
? $t('Show tracking ticket')
: $t('Show tracking tickets')
"
role="button"
tag="div"

View file

@ -61,6 +61,7 @@ describe('TicketChecklistBadges', () => {
checklist: {
id: convertToGraphQLId('Checklist', 1),
completed: false,
incomplete: 3,
total: 5,
complete: 2,
},
@ -83,6 +84,7 @@ describe('TicketChecklistBadges', () => {
checklist: {
id: convertToGraphQLId('Checklist', 1),
completed: false,
incomplete: 3,
total: 5,
complete: 2,
},
@ -109,6 +111,7 @@ describe('TicketChecklistBadges', () => {
checklist: {
id: convertToGraphQLId('Checklist', 1),
completed: false,
incomplete: 2,
total: 3,
complete: 1,
},
@ -146,6 +149,7 @@ describe('TicketChecklistBadges', () => {
id: convertToGraphQLId('Checklist', 1),
completed: false,
total: 3,
incomplete: 2,
complete: 1,
},
referencingChecklistTickets: [
@ -180,7 +184,7 @@ describe('TicketChecklistBadges', () => {
})
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Show tracked tickets' }),
wrapper.getByRole('button', { name: 'Show tracking tickets' }),
)
expect(
@ -212,6 +216,7 @@ describe('TicketChecklistBadges', () => {
id: convertToGraphQLId('Checklist', 1),
completed: false,
total: 3,
incomplete: 0,
complete: 3,
},
})

View file

@ -3,7 +3,7 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useTicketChecklist } from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/useTicketChecklist.ts'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import {
type TicketSidebarProps,
type TicketSidebarEmits,
@ -19,17 +19,20 @@ defineProps<TicketSidebarProps>()
const emit = defineEmits<TicketSidebarEmits>()
const { incompleteItemCount, checklist, isLoadingChecklist, isTicketEditable } =
useTicketChecklist()
const { ticket } = useTicketInformation()
const incompleteChecklistItemsCount = computed(
() => ticket.value?.checklist?.incomplete,
)
const badge = computed<TicketSidebarButtonBadgeDetails | undefined>(() => {
const label = __('Incomplete checklist items')
if (!incompleteItemCount.value) return
if (!incompleteChecklistItemsCount.value) return
return {
type: TicketSidebarButtonBadgeType.Info,
value: incompleteItemCount.value,
value: incompleteChecklistItemsCount.value,
label,
}
})
@ -50,9 +53,6 @@ onMounted(() => {
<TicketSidebarChecklistContent
:context="context"
:sidebar-plugin="sidebarPlugin"
:checklist="checklist"
:loading="isLoadingChecklist"
:readonly="!isTicketEditable"
/>
</TicketSidebarWrapper>
</template>

View file

@ -2,14 +2,13 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash-es'
import { computed, watch, nextTick, useTemplateRef } from 'vue'
import { computed, nextTick, useTemplateRef } from 'vue'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import { handleUserErrors } from '#shared/errors/utils.ts'
import type {
ChecklistItem,
TicketChecklistItemInput,
Checklist,
} from '#shared/graphql/types.ts'
import { i18n } from '#shared/i18n/index.ts'
import { getApolloClient } from '#shared/server/apollo/client.ts'
@ -23,6 +22,7 @@ import ChecklistItems from '#desktop/pages/ticket/components/TicketSidebar/Ticke
import ChecklistTemplates from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent/ChecklistTemplates.vue'
import type { AddNewChecklistInput } from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/types.ts'
import { useChecklistTemplates } from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/useChecklistTemplates.ts'
import { useTicketChecklist } from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/useTicketChecklist.ts'
import TicketSidebarContent from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarContent.vue'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { useTicketNumber } from '#desktop/pages/ticket/composables/useTicketNumber.ts'
@ -34,23 +34,19 @@ import { useTicketChecklistItemUpsertMutation } from '#desktop/pages/ticket/grap
import { useTicketChecklistTitleUpdateMutation } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistTitleUpdate.api.ts'
import type { TicketSidebarContentProps } from '#desktop/pages/ticket/types/sidebar.ts'
interface Props extends TicketSidebarContentProps {
checklist?: Checklist
loading: boolean
readonly: boolean
}
const props = defineProps<Props>()
defineProps<TicketSidebarContentProps>()
const checklistItemsInstance = useTemplateRef('checklist-items')
const { cache: apolloCache } = getApolloClient()
const { ticket } = useTicketInformation()
const { ticket, ticketId, isTicketEditable } = useTicketInformation()
const { ticketNumberWithTicketHook } = useTicketNumber(ticket)
const { checklist, isLoadingChecklist } = useTicketChecklist(ticketId, ticket)
const checklistTitle = computed(
() =>
props.checklist?.name ||
checklist.value?.name ||
i18n.t('%s Checklist', ticketNumberWithTicketHook.value),
)
@ -62,21 +58,16 @@ const createNewChecklist = async (
input?: Omit<AddNewChecklistInput, 'ticketId'>,
options = { focusLastItem: true },
) => {
if (options.focusLastItem)
watch(
checklistItemsInstance,
(component) => {
nextTick(() => component?.focusNewItem())
},
{ once: true },
)
if (ticket.value?.id) {
return addNewChecklistMutation
.send({
...input,
ticketId: ticket.value.id,
})
.then(() => {
if (options.focusLastItem)
nextTick(() => checklistItemsInstance.value?.focusNewItem())
})
.catch(handleUserErrors)
}
}
@ -97,7 +88,7 @@ const removeChecklist = async () => {
if (confirmed)
await checklistDeleteMutation
.send({
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
})
.catch(handleUserErrors)
}
@ -106,7 +97,7 @@ const updateTitle = async (title: string) => {
return checklistTitleUpdateMutation
.send({
title,
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
})
.then(() => {})
.catch(handleUserErrors)
@ -115,18 +106,19 @@ const updateTitle = async (title: string) => {
const itemAddMutation = new MutationHandler(
useTicketChecklistItemUpsertMutation({
update: (cache, { data }) => {
if (!data || !props.checklist) return
if (!data || !checklist.value) return
const { ticketChecklistItemUpsert } = data
if (!ticketChecklistItemUpsert?.checklistItem) return
const newIdPresent = props.checklist?.items.find((item) => {
const newIdPresent = checklist.value?.items.find((item) => {
return item.id === ticketChecklistItemUpsert.checklistItem?.id
})
if (newIdPresent) return
cache.modify({
id: cache.identify(props.checklist),
id: cache.identify(checklist.value),
fields: {
items(currentItems, { toReference }) {
return [
@ -134,8 +126,14 @@ const itemAddMutation = new MutationHandler(
toReference(ticketChecklistItemUpsert.checklistItem!),
]
},
incomplete() {
return (props.checklist?.incomplete || 0) + 1
complete(currentComplete) {
return currentComplete + 1
},
total(totalCount) {
return totalCount + 1
},
incomplete(incompleteCount) {
return incompleteCount + 1
},
},
})
@ -168,7 +166,7 @@ const itemDeleteMutation = new MutationHandler(
)
const modifyIncompleteItemCountCache = (increase: boolean) => {
const currentCheckList = props.checklist!
const currentCheckList = checklist.value!
const previousIncompleteItemCount = currentCheckList.incomplete
const previousCompleted = currentCheckList.completed
@ -259,7 +257,7 @@ const modifyCheckedCache = (item: ChecklistItem) => {
}
const modifyItemsCache = (items: ChecklistItem[]) => {
const currentCheckList = props.checklist!
const currentCheckList = checklist.value
const checklistId = apolloCache.identify(currentCheckList)
@ -277,7 +275,7 @@ const modifyItemsCache = (items: ChecklistItem[]) => {
const updateItem = async (itemId: string, input: TicketChecklistItemInput) => {
return itemUpsertMutation.send({
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
checklistItemId: itemId,
input,
})
@ -291,25 +289,19 @@ const setItemCheckedState = async (item: ChecklistItem) => {
})
}
const addNewItem = async () => {
watch(
() => props.checklist,
() => {
nextTick(() => checklistItemsInstance.value?.focusNewItem())
},
{ once: true },
)
return itemAddMutation
const addNewItem = async () =>
itemAddMutation
.send({
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
input: {
text: '',
checked: false,
},
})
.then(() => {
checklistItemsInstance.value?.focusNewItem()
})
.catch(handleUserErrors)
}
const editItem = async (item: ChecklistItem) => {
return updateItem(item.id, { text: item.text })
@ -320,7 +312,7 @@ const editItem = async (item: ChecklistItem) => {
const saveItemsOrder = (items: ChecklistItem[], stopReordering: () => void) => {
itemOrderMutation
.send({
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
order: items.map((item) => item.id),
})
.then(() => {
@ -342,7 +334,7 @@ const removeItem = async (item: ChecklistItem) => {
if (!confirmed) return
}
const previousChecklistItems = cloneDeep(props.checklist?.items || [])
const previousChecklistItems = cloneDeep(checklist.value?.items || [])
apolloCache.evict({ id: apolloCache.identify(item) })
apolloCache.gc()
@ -350,7 +342,7 @@ const removeItem = async (item: ChecklistItem) => {
return itemDeleteMutation
.send({
checklistId: props.checklist?.id as string,
checklistId: checklist.value?.id as string,
checklistItemId: item.id,
})
.catch((error) => {
@ -366,7 +358,7 @@ const checklistActions: MenuItem[] = [
label: __('Rename checklist'),
icon: 'input-cursor-text',
onClick: () => checklistItemsInstance.value?.focusTitle(),
show: () => !!props.checklist,
show: () => !!checklist.value,
},
{
key: 'remove',
@ -374,7 +366,7 @@ const checklistActions: MenuItem[] = [
variant: 'danger',
icon: 'trash3',
onClick: () => removeChecklist(),
show: () => !!props.checklist,
show: () => !!checklist.value,
},
]
@ -384,11 +376,11 @@ const { isLoadingTemplates, checklistTemplatesMenuItems } =
<template>
<TicketSidebarContent
:actions="readonly ? undefined : checklistActions"
:actions="!isTicketEditable ? undefined : checklistActions"
:title="sidebarPlugin.title"
:icon="sidebarPlugin.icon"
>
<CommonLoader :loading="loading">
<CommonLoader :loading="isLoadingChecklist">
<div class="flex flex-col gap-3">
<ChecklistItems
v-if="checklist"
@ -396,7 +388,7 @@ const { isLoadingTemplates, checklistTemplatesMenuItems } =
:no-default-title="!!checklist.name"
:title="checklistTitle"
:items="checklist?.items"
:read-only="readonly"
:read-only="!isTicketEditable"
@add-item="addNewItem"
@remove-item="removeItem"
@set-item-checked="setItemCheckedState"
@ -404,7 +396,7 @@ const { isLoadingTemplates, checklistTemplatesMenuItems } =
@save-order="saveItemsOrder"
@update-title="updateTitle"
/>
<template v-else-if="!readonly">
<template v-else-if="isTicketEditable">
<CommonButton
variant="primary"
size="medium"

View file

@ -1,7 +1,8 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
import { computed } from 'vue'
import { computed, type ComputedRef } from 'vue'
import type { TicketById } from '#shared/entities/ticket/types.ts'
import type {
Checklist,
TicketChecklistQuery,
@ -10,13 +11,16 @@ import type {
} from '#shared/graphql/types.ts'
import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { useTicketChecklistQuery } from '#desktop/pages/ticket/graphql/queries/ticketChecklist.api.ts'
import { TicketChecklistUpdatesDocument } from '#desktop/pages/ticket/graphql/subscriptions/ticketChecklistUpdates.api.ts'
export const useTicketChecklist = () => {
const { ticket, ticketId, isTicketEditable } = useTicketInformation()
export const useTicketChecklist = (
/**
* TicketId is always available since we use it from the route not `ticket` directly
*/
ticketId: ComputedRef<string>,
ticket: ComputedRef<TicketById | undefined>,
) => {
const checklistQuery = new QueryHandler(
useTicketChecklistQuery(() => ({
ticketId: ticketId.value,
@ -76,7 +80,6 @@ export const useTicketChecklist = () => {
return {
checklist,
incompleteItemCount,
isTicketEditable,
isLoadingChecklist,
}
}

View file

@ -2,7 +2,6 @@ import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { ObjectAttributeValuesFragmentDoc } from '../../../../graphql/fragments/objectAttributeValues.api';
import { ReferencingTicketFragmentDoc } from './referencingTicket.api';
export const TicketAttributesFragmentDoc = gql`
fragment ticketAttributes on Ticket {
id
@ -101,15 +100,5 @@ export const TicketAttributesFragmentDoc = gql`
closeEscalationAt
updateEscalationAt
initialChannel
checklist {
id
completed
total
complete
}
referencingChecklistTickets {
...referencingTicket
}
}
${ObjectAttributeValuesFragmentDoc}
${ReferencingTicketFragmentDoc}`;
${ObjectAttributeValuesFragmentDoc}`;

View file

@ -95,13 +95,4 @@ fragment ticketAttributes on Ticket {
closeEscalationAt
updateEscalationAt
initialChannel
checklist {
id
completed
total
complete
}
referencingChecklistTickets {
...referencingTicket
}
}

View file

@ -2,8 +2,8 @@ import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { TicketAttributesFragmentDoc } from '../fragments/ticketAttributes.api';
import { ReferencingTicketFragmentDoc } from '../fragments/referencingTicket.api';
import { TicketMentionFragmentDoc } from '../fragments/ticketMention.api';
import { ReferencingTicketFragmentDoc } from '../fragments/referencingTicket.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
@ -30,6 +30,7 @@ export const TicketDocument = gql`
checklist {
id
completed
incomplete
total
complete
}

View file

@ -23,6 +23,7 @@ query ticket($ticketId: ID, $ticketInternalId: Int, $ticketNumber: String) {
checklist {
id
completed
incomplete
total
complete
}

View file

@ -2,8 +2,8 @@ import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import { TicketAttributesFragmentDoc } from '../fragments/ticketAttributes.api';
import { ReferencingTicketFragmentDoc } from '../fragments/referencingTicket.api';
import { TicketMentionFragmentDoc } from '../fragments/ticketMention.api';
import { ReferencingTicketFragmentDoc } from '../fragments/referencingTicket.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
@ -29,6 +29,7 @@ export const TicketUpdatesDocument = gql`
checklist {
id
completed
incomplete
total
complete
}

View file

@ -18,6 +18,7 @@ subscription ticketUpdates($ticketId: ID!, $initial: Boolean = false) {
checklist {
id
completed
incomplete
total
complete
}

View file

@ -37,6 +37,7 @@ export interface TicketLiveAppUser {
}
export type TicketById = TicketQuery['ticket']
export type TicketArticle = ConfidentTake<
TicketArticlesQuery,
'articles.edges.node'

View file

@ -5451,7 +5451,7 @@ export type TicketWithMentionLimitQueryVariables = Exact<{
}>;
export type TicketWithMentionLimitQuery = { __typename?: 'Queries', ticket: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null } };
export type TicketWithMentionLimitQuery = { __typename?: 'Queries', ticket: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } };
export type TicketOverviewUpdatesSubscriptionVariables = Exact<{
withTicketCount: Scalars['Boolean']['input'];
@ -5851,7 +5851,7 @@ export type ReferencingTicketFragment = { __typename?: 'Ticket', id: string, int
export type TicketArticleAttributesFragment = { __typename?: 'TicketArticle', id: string, internalId: number, messageId?: string | null, subject?: string | null, messageIdMd5?: string | null, inReplyTo?: string | null, contentType: string, preferences?: any | null, bodyWithUrls: string, internal: boolean, createdAt: string, from?: { __typename?: 'AddressesField', raw: string, parsed?: Array<{ __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null, isSystemAddress: boolean }> | null } | null, to?: { __typename?: 'AddressesField', raw: string, parsed?: Array<{ __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null, isSystemAddress: boolean }> | null } | null, cc?: { __typename?: 'AddressesField', raw: string, parsed?: Array<{ __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null, isSystemAddress: boolean }> | null } | null, replyTo?: { __typename?: 'AddressesField', raw: string, parsed?: Array<{ __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null, isSystemAddress: boolean }> | null } | null, attachmentsWithoutInline: Array<{ __typename?: 'StoredFile', id: string, internalId: number, name: string, size?: number | null, type?: string | null, preferences?: any | null }>, author: { __typename?: 'User', id: string, fullname?: string | null, firstname?: string | null, lastname?: string | null, email?: string | null, active?: boolean | null, image?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, authorizations?: Array<{ __typename?: 'Authorization', provider: string, uid: string, username?: string | null }> | null }, type?: { __typename?: 'TicketArticleType', name?: string | null, communication?: boolean | null } | null, sender?: { __typename?: 'TicketArticleSender', name?: EnumTicketArticleSenderName | null } | null, securityState?: { __typename?: 'TicketArticleSecurityState', encryptionMessage?: string | null, encryptionSuccess?: boolean | null, signingMessage?: string | null, signingSuccess?: boolean | null, type?: EnumSecurityStateType | null } | null, mediaErrorState?: { __typename?: 'TicketArticleMediaErrorState', error?: boolean | null } | null };
export type TicketAttributesFragment = { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null };
export type TicketAttributesFragment = { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null };
export type TicketLiveUserAttributesFragment = { __typename?: 'TicketLiveUser', user: { __typename?: 'User', id: string, firstname?: string | null, lastname?: string | null, fullname?: string | null, email?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null }, apps: Array<{ __typename?: 'TicketLiveUserApp', name: EnumTaskbarApp, editing: boolean, lastInteraction: string }> };
@ -5864,7 +5864,7 @@ export type TicketCreateMutationVariables = Exact<{
}>;
export type TicketCreateMutation = { __typename?: 'Mutations', ticketCreate?: { __typename?: 'TicketCreatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketCreateMutation = { __typename?: 'Mutations', ticketCreate?: { __typename?: 'TicketCreatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketCustomerUpdateMutationVariables = Exact<{
ticketId: Scalars['ID']['input'];
@ -5872,7 +5872,7 @@ export type TicketCustomerUpdateMutationVariables = Exact<{
}>;
export type TicketCustomerUpdateMutation = { __typename?: 'Mutations', ticketCustomerUpdate?: { __typename?: 'TicketCustomerUpdatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketCustomerUpdateMutation = { __typename?: 'Mutations', ticketCustomerUpdate?: { __typename?: 'TicketCustomerUpdatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketMergeMutationVariables = Exact<{
sourceTicketId: Scalars['ID']['input'];
@ -5903,7 +5903,7 @@ export type TicketUpdateMutationVariables = Exact<{
}>;
export type TicketUpdateMutation = { __typename?: 'Mutations', ticketUpdate?: { __typename?: 'TicketUpdatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketUpdateMutation = { __typename?: 'Mutations', ticketUpdate?: { __typename?: 'TicketUpdatePayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketQueryVariables = Exact<{
ticketId?: InputMaybe<Scalars['ID']['input']>;
@ -5912,7 +5912,7 @@ export type TicketQueryVariables = Exact<{
}>;
export type TicketQuery = { __typename?: 'Queries', ticket: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } };
export type TicketQuery = { __typename?: 'Queries', ticket: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, incomplete: number, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } };
export type TicketArticlesQueryVariables = Exact<{
ticketId?: InputMaybe<Scalars['ID']['input']>;
@ -5957,7 +5957,7 @@ export type TicketUpdatesSubscriptionVariables = Exact<{
}>;
export type TicketUpdatesSubscription = { __typename?: 'Subscriptions', ticketUpdates: { __typename?: 'TicketUpdatesPayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } | null } };
export type TicketUpdatesSubscription = { __typename?: 'Subscriptions', ticketUpdates: { __typename?: 'TicketUpdatesPayload', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: string, escalationAt?: string | null, updatedAt: string, pendingTime?: string | null, tags?: Array<string> | null, timeUnit?: number | null, subscribed?: boolean | null, preferences?: any | null, stateColorCode: EnumTicketStateColorCode, sharedDraftZoomId?: string | null, firstResponseEscalationAt?: string | null, closeEscalationAt?: string | null, updateEscalationAt?: string | null, initialChannel?: EnumChannelArea | null, createArticleType?: { __typename?: 'TicketArticleType', id: string, name?: string | null } | null, mentions?: { __typename?: 'MentionConnection', totalCount: number, edges: Array<{ __typename?: 'MentionEdge', cursor: string, node: { __typename?: 'Mention', user: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, vip?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, active?: boolean | null, image?: string | null } } }> } | null, checklist?: { __typename?: 'Checklist', id: string, completed: boolean, incomplete: number, total: number, complete: number } | null, referencingChecklistTickets?: Array<{ __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } }> | null, updatedBy?: { __typename?: 'User', id: string } | null, owner: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, fullname?: string | null, phone?: string | null, mobile?: string | null, image?: string | null, vip?: boolean | null, active?: boolean | null, outOfOffice?: boolean | null, outOfOfficeStartAt?: string | null, outOfOfficeEndAt?: string | null, email?: string | null, hasSecondaryOrganizations?: boolean | null, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, active?: boolean | null, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null } | null, policy: { __typename?: 'PolicyDefault', update: boolean } }, organization?: { __typename?: 'Organization', id: string, internalId: number, name?: string | null, vip?: boolean | null, active?: boolean | null } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', id: string, name: string } }, group: { __typename?: 'Group', id: string, name?: string | null, emailAddress?: { __typename?: 'EmailAddressParsed', name?: string | null, emailAddress?: string | null } | null }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null }, objectAttributeValues?: Array<{ __typename?: 'ObjectAttributeValue', value?: any | null, renderedLink?: string | null, attribute: { __typename?: 'ObjectManagerFrontendAttribute', name: string, display: string } }> | null, policy: { __typename?: 'PolicyTicket', update: boolean, agentReadAccess: boolean }, timeUnitsPerType?: Array<{ __typename?: 'TicketTimeAccountingTypeSum', name: string, timeUnit: number }> | null } | null } };
export type TokenAttributesFragment = { __typename?: 'Token', id: string, name?: string | null, preferences?: any | null, expiresAt?: string | null, lastUsedAt?: string | null, createdAt: string, user?: { __typename?: 'User', id: string } | null };

View file

@ -0,0 +1,13 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
module Gql::Concerns::EnsuresChecklistFeatureActive
extend ActiveSupport::Concern
included do
def self.ensure_checklist_feature_active!
raise Exceptions::Forbidden, 'The checklist feature is not active' if !Setting.get('checklist') # rubocop:disable Zammad/DetectTranslatableString
end
end
end

View file

@ -2,9 +2,12 @@
module Gql::Mutations
class Ticket::Checklist::Base < BaseMutation
include Gql::Concerns::EnsuresChecklistFeatureActive
description 'Base class for checklist mutations.'
def self.authorize(_obj, ctx)
ensure_checklist_feature_active!
ctx.current_user.permissions?(['ticket.agent'])
end
end

View file

@ -2,6 +2,7 @@
module Gql::Queries
class Checklist::Templates < BaseQuery
include Gql::Concerns::EnsuresChecklistFeatureActive
description 'Fetch checklist templates'
@ -10,6 +11,7 @@ module Gql::Queries
type [Gql::Types::Checklist::TemplateType, { null: false }], null: false
def self.authorize(_obj, ctx)
ensure_checklist_feature_active!
ctx.current_user.permissions?(['ticket.agent'])
end

View file

@ -2,6 +2,7 @@
module Gql::Queries
class Ticket::Checklist < BaseQuery
include Gql::Concerns::EnsuresChecklistFeatureActive
description 'Fetch ticket checklist'
@ -10,6 +11,7 @@ module Gql::Queries
type Gql::Types::ChecklistType, null: true
def self.authorize(_obj, ctx)
ensure_checklist_feature_active!
ctx.current_user.permissions?(['ticket.agent'])
end

View file

@ -2,6 +2,7 @@
module Gql::Subscriptions
class Checklist::TemplateUpdates < BaseSubscription
include Gql::Concerns::EnsuresChecklistFeatureActive
description 'Subscription for checklist template changes.'
@ -9,6 +10,11 @@ module Gql::Subscriptions
field :checklist_templates, [Gql::Types::Checklist::TemplateType, { null: false }], description: 'Checklist templates'
def self.authorize(_obj, ctx)
ensure_checklist_feature_active!
super
end
def authorized?(only_active:)
context.current_user.permissions?('ticket.agent')
end

View file

@ -2,6 +2,7 @@
module Gql::Subscriptions
class Ticket::ChecklistUpdates < BaseSubscription
include Gql::Concerns::EnsuresChecklistFeatureActive
description 'Subscription for ticket checklist changes.'
@ -10,6 +11,11 @@ module Gql::Subscriptions
field :ticket_checklist, Gql::Types::ChecklistType, description: 'Ticket checklist'
field :removed_ticket_checklist, Boolean, description: 'Ticket checklist was removed from ticket'
def self.authorize(_obj, ctx)
ensure_checklist_feature_active!
super
end
def authorized?(ticket_id:)
context.current_user.permissions?('ticket.agent') && Gql::ZammadSchema.authorized_object_from_id(ticket_id, type: ::Ticket, user: context.current_user)
end

View file

@ -114,7 +114,15 @@ module Gql::Types
Gql::ZammadSchema.id_from_object(@object.shared_draft)
end
def checklist
return nil if !Setting.get('checklist')
@object.checklist
end
def referencing_checklist_tickets
return nil if !Setting.get('checklist')
::Checklist.tickets_referencing(@object, context.current_user)
end

View file

@ -11,11 +11,8 @@ class Checklist < ApplicationModel
belongs_to :ticket
has_many :items, inverse_of: :checklist, dependent: :destroy
scope :for_user, ->(user) { joins(:ticket).where(ticket: { group: user.group_ids_access('read') }) }
before_validation :ensure_text_not_nil
validates :ticket_id, uniqueness: true
validates :name, length: { maximum: 250 }
history_attributes_ignored :sorted_item_ids
@ -55,9 +52,7 @@ class Checklist < ApplicationModel
end
def incomplete
Auth::RequestCache.fetch_value("Checklist/#{id}/incomplete") do
items.count(&:incomplete?)
end
items.incomplete.count
end
def total
@ -88,12 +83,15 @@ class Checklist < ApplicationModel
.resolve
end
private
def self.ticket_closed?(ticket)
state = Ticket::State.lookup id: ticket.state_id
state_type = Ticket::StateType.lookup id: state.state_type_id
def ensure_text_not_nil
self.name ||= ''
%w[closed merged].include? state_type.name
end
private
def update_ticket
ticket.updated_at = Time.current
ticket.save!

View file

@ -14,7 +14,13 @@ class Checklist
return data if data[ app_model ][ id ]
data[ app_model ][ id ] = attributes_with_association_ids
items.map { |item| data = item.assets(data) }
if ticket && !ticket.authorized_asset?
data[self.class.to_app_model][id]['ticket_inaccessible'] = true
end
items.each { |elem| elem.assets(data) }
data
end
end

View file

@ -10,20 +10,27 @@ class Checklist::Item < ApplicationModel
attr_accessor :initial_clone
belongs_to :checklist
belongs_to :ticket, optional: true
belongs_to :ticket, optional: true, inverse_of: :referencing_checklist_items
scope :for_user, ->(user) { joins(checklist: :ticket).where(tickets: { group: user.group_ids_access('read') }) }
scope :incomplete, -> { where(checked: false) }
before_validation :detect_ticket_reference, unless: :initial_clone
before_validation :ensure_text_not_nil
before_validation :detect_ticket_reference_state
validate :detect_ticket_loop_reference, on: %i[create update], unless: -> { ticket.blank? || checklist.blank? }
validate :validate_item_count, on: :create
validate :detect_ticket_loop_reference, unless: -> { ticket.blank? || checklist.blank? }
validate :validate_item_count, on: :create, unless: -> { checklist.blank? }
# MySQL does not support default value on non-null text columns
# Can be removed after dropping MySQL
before_validation :ensure_text_not_nil, if: -> { ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == 'mysql2' }
after_update :history_update_checked, if: -> { saved_change_to_checked? }
after_destroy :update_checklist_on_destroy
after_destroy :update_referenced_ticket
after_save :update_checklist_on_save
after_save :update_referenced_ticket
history_attributes_ignored :checked
def history_log_attributes
@ -56,12 +63,6 @@ class Checklist::Item < ApplicationModel
}
end
def incomplete?
return ticket_incomplete? if ticket.present?
!checked
end
private
def update_checklist_on_save
@ -81,7 +82,7 @@ class Checklist::Item < ApplicationModel
end
def detect_ticket_reference
return if changes.key?(:ticket_id)
return if ticket_id_changed?
ticket = Ticket::Number.check(text)
return if ticket.blank?
@ -89,6 +90,13 @@ class Checklist::Item < ApplicationModel
self.ticket = ticket
end
def detect_ticket_reference_state
return if !ticket
return if !ticket_id_changed?
self.checked = Checklist.ticket_closed?(ticket)
end
def detect_ticket_loop_reference
return if ticket.id != checklist.ticket.id
@ -101,13 +109,20 @@ class Checklist::Item < ApplicationModel
errors.add(:base, __('Checklist items are limited to 100 items per checklist.'))
end
def ticket_incomplete?
# Consider the following ticket state types as incomplete:
# - closed
# - merged
!ticket.state.state_type.name.match?(%r{^(closed|merged)$}i)
def update_referenced_ticket
return if !saved_change_to_ticket_id? && !destroyed?
[ticket_id, ticket_id_before_last_save]
.compact
.map { |elem| Ticket.find_by(id: elem) }
.each do |elem|
elem.updated_at = Time.current
elem.save!
end
end
# MySQL does not support default value on non-null text columns
# Can be removed after dropping MySQL
def ensure_text_not_nil
self.text ||= ''
end

View file

@ -4,28 +4,21 @@ class Checklist::Item
module Assets
extend ActiveSupport::Concern
def assets(...)
data = super
def assets(data)
app_model = self.class.to_app_model
if !data[ app_model ]
data[ app_model ] = {}
end
Rails.logger.debug [app_model, id, data[app_model]]
return data if data[ app_model ][ id ]
data[ app_model ][ id ] = attributes_with_association_ids
checklist.assets(data)
ticket&.assets(data) if ticket&.authorized_asset?
add_referencing_ticket_assets(data)
checklist.assets(data)
data
end
private
def add_referencing_ticket_assets(data)
return if !ticket
if !checklist.ticket.authorized_asset?
data[self.class.to_app_model][id]['ticket_inaccessible'] = true
return
end
ticket.assets(data)
end
end
end

View file

@ -9,7 +9,7 @@ class ChecklistTemplate < ApplicationModel
has_many :items, inverse_of: :checklist_template, dependent: :destroy
validates :name, presence: { allow_blank: true }
validates :name, length: { maximum: 250 }
def create_from_template!(ticket_id:)
raise ActiveRecord::RecordInvalid if !active

View file

@ -3,16 +3,17 @@
class ChecklistTemplate::Item < ApplicationModel
include ChecksClientNotification
include HasDefaultModelUserRelations
include ChecklistTemplate::TriggersSubscriptions
include ChecklistTemplate::Item::Assets
belongs_to :checklist_template
# MySQL does not support default value on non-null text columns
# Can be removed after dropping MySQL
before_validation :ensure_text_not_nil, if: -> { ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == 'mysql2' }
after_create :update_checklist
after_destroy :update_checklist
validates :text, presence: { allow_blank: true }
validate :validate_item_count, on: :create
validate :validate_item_count, on: :create, unless: -> { checklist_template.blank? }
private
@ -31,4 +32,10 @@ class ChecklistTemplate::Item < ApplicationModel
errors.add(:base, __('Checklist Template items are limited to 100 items per checklist.'))
end
# MySQL does not support default value on non-null text columns
# Can be removed after dropping MySQL
def ensure_text_not_nil
self.text ||= ''
end
end

View file

@ -1,20 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class ChecklistTemplate::Item
module Assets
extend ActiveSupport::Concern
def assets(data)
app_model = self.class.to_app_model
if !data[ app_model ]
data[ app_model ] = {}
end
return data if data[ app_model ][ id ]
data[ app_model ][ id ] = attributes_with_association_ids
checklist_template.assets(data)
data
end
end
end

View file

@ -15,7 +15,7 @@ module CanChecklistSortedItems
end
def default_sorted_item_ids
self.sorted_item_ids = [] if sorted_item_ids.nil?
self.sorted_item_ids ||= []
end
end
end

View file

@ -22,6 +22,7 @@ class Ticket < ApplicationModel
include Ticket::TouchesAssociations
include Ticket::TriggersSubscriptions
include Ticket::ChecksReopenAfterCertainTime
include Ticket::Checklists
include ::Ticket::Escalation
include ::Ticket::Subject
@ -95,9 +96,7 @@ class Ticket < ApplicationModel
has_many :articles, -> { reorder(:created_at, :id) }, class_name: 'Ticket::Article', after_add: :cache_update, after_remove: :cache_update, dependent: :destroy, inverse_of: :ticket
has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket
has_many :mentions, as: :mentionable, dependent: :destroy
has_many :checklist_items, class_name: 'Checklist::Item', dependent: :nullify
has_one :shared_draft, class_name: 'Ticket::SharedDraftZoom', inverse_of: :ticket, dependent: :destroy
has_one :checklist, dependent: :destroy
belongs_to :state, class_name: 'Ticket::State', optional: true
belongs_to :priority, class_name: 'Ticket::Priority', optional: true
belongs_to :owner, class_name: 'User', optional: true

View file

@ -23,20 +23,30 @@ returns
=end
def assets(data)
app_model_ticket = Ticket.to_app_model
app_model = self.class.to_app_model
if !data[ app_model_ticket ]
data[ app_model_ticket ] = {}
if !data[ app_model ]
data[ app_model ] = {}
end
return data if data[ app_model_ticket ][ id ]
Rails.logger.debug [app_model, id, data[app_model]]
return data if data[ app_model ][ id ]
data[app_model_ticket][id] = attributes_with_association_ids
data[ app_model ][ id ] = attributes_with_association_ids
group.assets(data)
organization&.assets(data)
checklist&.assets(data)
assets_user(data)
if Setting.get('checklist')
checklist&.assets(data)
referencing_checklists
.includes(:ticket)
.each do |elem|
elem.assets(data)
elem.ticket.assets(data) if elem.ticket.authorized_asset?
end
end
data
end

View file

@ -0,0 +1,39 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
module Ticket::Checklists
extend ActiveSupport::Concern
included do
has_many :referencing_checklist_items, class_name: 'Checklist::Item', dependent: :nullify
has_many :referencing_checklists, class_name: 'Checklist', through: :referencing_checklist_items, source: :checklist
has_one :checklist, dependent: :destroy
after_save :update_referenced_checklist_items
association_attributes_ignored :referencing_checklist_items
end
def attributes_with_association_ids
attributes = super
return attributes if !Setting.get('checklist')
attributes['checklist_id'] = checklist&.id
attributes['checklist_incomplete'] = checklist&.incomplete
attributes['checklist_total'] = checklist&.total
attributes
end
private
def update_referenced_checklist_items
return if !saved_change_to_state_id?
is_closed = Checklist.ticket_closed?(self)
referencing_checklist_items
.where(checked: !is_closed)
.each { |elem| elem.update! checked: is_closed }
end
end

View file

@ -15,15 +15,13 @@ module Ticket::TriggersSubscriptions
Gql::Subscriptions::TicketUpdates.trigger(self, arguments: { ticket_id: Gql::ZammadSchema.id_from_object(self) })
end
TRIGGER_CHECKLIST_UPDATE_ON = %w[title state_id group_id].freeze
TRIGGER_CHECKLIST_UPDATE_ON = %w[title group_id].freeze
def trigger_checklist_subscriptions
return if !saved_changes.keys.intersect? TRIGGER_CHECKLIST_UPDATE_ON
referenced_in_checklists = checklist_items.pluck(:checklist_id)
Checklist
.where(id: referenced_in_checklists)
.where(id: referencing_checklists)
.includes(:ticket)
.each do |elem|
Gql::Subscriptions::Ticket::ChecklistUpdates.trigger(

View file

@ -6,6 +6,6 @@ class Checklist::ItemPolicy < ApplicationPolicy
private
def checklist_policy
ChecklistPolicy.new(user, record.checklist)
ChecklistPolicy.new(user, record&.checklist)
end
end

View file

@ -2,23 +2,27 @@
class ChecklistPolicy < ApplicationPolicy
def show?
ticket_policy.agent_read_access?
check_prerequisites? && ticket_policy.agent_read_access?
end
def create?
ticket_policy.agent_update_access?
check_prerequisites? && ticket_policy.agent_update_access?
end
def update?
ticket_policy.agent_update_access?
check_prerequisites? && ticket_policy.agent_update_access?
end
def destroy?
ticket_policy.agent_update_access?
check_prerequisites? && ticket_policy.agent_update_access?
end
private
def check_prerequisites?
Setting.get('checklist') && record&.ticket
end
def ticket_policy
TicketPolicy.new(user, record.ticket)
end

View file

@ -2,23 +2,27 @@
class ChecklistTemplatePolicy < ApplicationPolicy
def show?
agent? || admin?
checklist_feature_enabled? && (agent? || admin?)
end
def create?
admin?
checklist_feature_enabled? && admin?
end
def update?
admin?
checklist_feature_enabled? && admin?
end
def destroy?
admin?
checklist_feature_enabled? && admin?
end
private
def checklist_feature_enabled?
Setting.get('checklist')
end
def agent?
user.permissions?('ticket.agent')
end

View file

@ -2,27 +2,36 @@
class Controllers::ChecklistItemsControllerPolicy < Controllers::ApplicationControllerPolicy
def create?
update_access_via_ticket?
Checklist::ItemPolicy
.new(user, checklist&.items&.build)
.create?
end
def show?
Checklist::ItemPolicy
.new(user, checklist_item)
.show?
end
def update?
update_access_via_ticket?
Checklist::ItemPolicy
.new(user, checklist_item)
.update?
end
def destroy?
update_access_via_ticket?
Checklist::ItemPolicy
.new(user, checklist_item)
.destroy?
end
private
def ticket_policy
ticket = Checklist.lookup(id: record.params[:checklist_id])&.ticket || Checklist::Item.lookup(id: record.params[:id])&.checklist&.ticket
@ticket_policy ||= TicketPolicy.new(user, ticket)
def checklist
Checklist.lookup(id: record.params[:checklist_id])
end
def update_access_via_ticket?
user.permissions?(['ticket.agent']) && ticket_policy.agent_update_access?
def checklist_item
Checklist::Item.lookup(id: record.params[:id])
end
default_permit!(['ticket.agent'])
end

View file

@ -1,6 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class Controllers::ChecklistTemplateItemsControllerPolicy < Controllers::ApplicationControllerPolicy
permit! %i[index show], to: ['ticket.agent', 'admin.checklist']
default_permit!(['admin.checklist'])
end

View file

@ -2,27 +2,40 @@
class Controllers::ChecklistsControllerPolicy < Controllers::ApplicationControllerPolicy
def create?
update_access_via_ticket?
ChecklistPolicy
.new(user, ticket&.build_checklist)
.create?
end
def update?
update_access_via_ticket?
ChecklistPolicy
.new(user, checklist)
.update?
end
def destroy?
update_access_via_ticket?
ChecklistPolicy
.new(user, checklist)
.destroy?
end
def show?
ChecklistPolicy
.new(user, checklist)
.show?
end
def show_by_ticket?
user.permissions?('ticket.agent')
end
private
def ticket_policy
ticket = Checklist.lookup(id: record.params[:id])&.ticket || Ticket.lookup(id: record.params[:ticket_id])
@ticket_policy ||= TicketPolicy.new(user, ticket)
def checklist
Checklist.lookup(id: record.params[:id])
end
def update_access_via_ticket?
user.permissions?(['ticket.agent']) && ticket_policy.agent_update_access?
def ticket
Ticket.lookup(id: record.params[:ticket_id])
end
default_permit!(['ticket.agent'])
end

View file

@ -17,10 +17,6 @@ class Controllers::TicketChecklistControllerPolicy < Controllers::ApplicationCon
update_access_via_ticket?
end
def completed?
update_access_via_ticket?
end
private
def ticket_policy

View file

@ -1,25 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class Controllers::TicketChecklistItemsControllerPolicy < Controllers::ApplicationControllerPolicy
def create?
update_access_via_ticket?
end
def destroy?
update_access_via_ticket?
end
def update?
update_access_via_ticket?
end
private
def ticket_policy
@ticket_policy ||= TicketPolicy.new(user, Ticket.lookup(id: record.params[:ticket_id]))
end
def update_access_via_ticket?
ticket_policy.agent_update_access?
end
end

View file

@ -20,10 +20,14 @@ class Service::Ticket::Update::Validator
private
def ticket_closed?
return false if !ticket_data[:state]
ticket_data[:state].state_type.name == 'closed'
end
def ticket_pending_close?
return false if !ticket_data[:state]
ticket_data[:state].state_type.name == 'pending action' && ticket_data[:state].next_state.state_type.name == 'closed'
end
end

View file

@ -2,6 +2,13 @@
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
resources :checklists, only: %i[index show create update destroy]
resources :checklists, only: %i[index show create update destroy] do
collection do
get 'by_ticket/:ticket_id', to: 'checklists#show_by_ticket'
end
end
resources :checklist_items, only: %i[create update destroy show]
resources :checklist_templates, only: %i[index show create update destroy]
end
end

View file

@ -1,7 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
resources :checklist_items, only: %i[index show create update destroy]
end
end

View file

@ -1,7 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
resources :checklist_templates, only: %i[index show create update destroy]
end
end

View file

@ -1,7 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
resources :checklist_template_items, only: %i[index show create update destroy]
end
end

View file

@ -69,16 +69,4 @@ Zammad::Application.routes.draw do
match api_path + '/ticket_article_plain/:id', to: 'ticket_articles#article_plain', via: :get
match api_path + '/ticket_articles/:id/retry_security_process', to: 'ticket_articles#retry_security_process', via: :post
match api_path + '/ticket_articles/:id/retry_whatsapp_attachment_download', to: 'ticket_articles#retry_whatsapp_attachment_download', via: :post
# ticket checklist
match api_path + '/tickets/:ticket_id/checklist/completed', to: 'ticket_checklist#completed', via: :get
scope Rails.configuration.api_path do
scope 'tickets/:ticket_id' do
resource 'checklist', controller: 'ticket_checklist', only: %i[show create destroy update] do
member do
resources :items, controller: 'ticket_checklist_items', only: %i[create update destroy]
end
end
end
end
end

View file

@ -549,7 +549,7 @@ class CreateTicket < ActiveRecord::Migration[4.2]
end
create_table :checklists do |t|
t.string :name, limit: 250, null: false
t.string :name, limit: 250, null: false, default: ''
if Rails.application.config.db_column_array
t.string :sorted_item_ids, null: false, array: true, default: []
else
@ -557,12 +557,16 @@ class CreateTicket < ActiveRecord::Migration[4.2]
end
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.references :updated_by, null: false, foreign_key: { to_table: :users }
t.references :ticket, null: true, foreign_key: true, index: { unique: true }
t.references :ticket, null: false, foreign_key: true, index: { unique: true }
t.timestamps limit: 3, null: false
end
create_table :checklist_items do |t|
t.text :text, null: false
if ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == 'mysql2'
t.text :text, null: false
else
t.text :text, null: false, default: ''
end
t.boolean :checked, null: false, default: false
t.references :checklist, null: false, foreign_key: true
t.references :created_by, null: false, foreign_key: { to_table: :users }
@ -570,9 +574,10 @@ class CreateTicket < ActiveRecord::Migration[4.2]
t.references :ticket, null: true, foreign_key: true
t.timestamps limit: 3, null: false
end
add_index :checklist_items, [:checked]
create_table :checklist_templates do |t|
t.string :name, limit: 250, null: false
t.string :name, limit: 250, null: false, default: ''
t.boolean :active, default: true, null: false
if Rails.application.config.db_column_array
t.string :sorted_item_ids, null: false, array: true, default: []
@ -583,9 +588,14 @@ class CreateTicket < ActiveRecord::Migration[4.2]
t.references :updated_by, null: false, foreign_key: { to_table: :users }
t.timestamps limit: 3, null: false
end
add_index :checklist_templates, [:active]
create_table :checklist_template_items do |t|
t.text :text, null: false
if ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == 'mysql2'
t.text :text, null: false
else
t.text :text, null: false, default: ''
end
t.references :checklist_template, null: false, foreign_key: true
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.references :updated_by, null: false, foreign_key: { to_table: :users }

View file

@ -0,0 +1,30 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class ChecklistImproveReferenceTracking < ActiveRecord::Migration[7.1]
def change
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
add_default_empty_strings
add_indexes
change_column_null :checklists, :ticket_id, false
end
private
def add_default_empty_strings
change_column_default :checklists, :name, ''
change_column_default :checklist_templates, :name, ''
# MySQL does not support default text on non-null text items
return if ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == 'mysql2'
change_column_default :checklist_items, :text, ''
change_column_default :checklist_template_items, :text, ''
end
def add_indexes
add_index :checklist_items, :checked
add_index :checklist_templates, :active
end
end

View file

@ -0,0 +1,16 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
class SynchronizeChecklistItemStateFromTickets < ActiveRecord::Migration[7.1]
def change
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
update_checklist_items
end
def update_checklist_items
Checklist::Item.where.not(ticket_id: nil).each do |item|
item.update!(checked: Checklist.ticket_closed?(item.ticket))
end
end
end

View file

@ -136,7 +136,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist_show.coffee:42
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_checklist_title_edit.jst.eco:6
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:54
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:50
msgid "%s Checklist"
msgstr ""
@ -596,7 +596,7 @@ msgstr ""
msgid "A test ticket has been created, you can find it in your overview \"%s\" %l."
msgstr ""
#: app/models/ticket.rb:277
#: app/models/ticket.rb:276
msgid "A ticket cannot be merged into itself."
msgstr ""
@ -944,7 +944,7 @@ msgstr ""
msgid "Add Certificate"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:414
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:417
msgid "Add Empty Checklist"
msgstr ""
@ -1329,10 +1329,6 @@ msgstr ""
msgid "Allow reopening of tickets within a certain time."
msgstr ""
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:18
msgid "Allow users to add new checklists."
msgstr ""
#: app/assets/javascripts/app/views/tag/index.jst.eco:16
msgid "Allow users to add new tags."
msgstr ""
@ -2677,12 +2673,12 @@ msgstr ""
msgid "Checklist Template items are limited to 100 items per checklist."
msgstr ""
#: app/models/checklist/item.rb:101
#: app/models/checklist/item.rb:109
msgid "Checklist items are limited to 100 items per checklist."
msgstr ""
#: app/assets/javascripts/app/controllers/checklist_template.coffee:3
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:3
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:7
#: db/seeds/permissions.rb:299
#: db/seeds/settings.rb:5790
msgid "Checklists"
@ -3595,7 +3591,7 @@ msgstr ""
#: app/assets/javascripts/app/models/checklist.coffee:9
#: app/assets/javascripts/app/models/checklist_item.coffee:8
#: app/assets/javascripts/app/models/checklist_template.coffee:10
#: app/assets/javascripts/app/models/checklist_template_item.coffee:8
#: app/assets/javascripts/app/models/checklist_template_item.coffee:6
#: app/assets/javascripts/app/models/knowledge_base_answer_translation.coffee:7
#: app/assets/javascripts/app/models/organization.coffee:9
#: app/assets/javascripts/app/models/ticket.coffee:28
@ -5522,10 +5518,6 @@ msgstr ""
msgid "Enable Chat"
msgstr ""
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:16
msgid "Enable Checklists"
msgstr ""
#: app/assets/javascripts/app/views/api.jst.eco:33
#: db/seeds/settings.rb:3411
msgid "Enable REST API access using the username/email address and password for the authentication user."
@ -6140,11 +6132,11 @@ msgstr ""
msgid "Failed Tasks"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:145
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:148
msgid "Failed to add new checklist item."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:166
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:169
msgid "Failed to delete checklist item."
msgstr ""
@ -6162,11 +6154,11 @@ msgstr ""
msgid "Failed to roll back the migration of the channel!"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:152
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:155
msgid "Failed to save checklist order."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist_show.coffee:117
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist_show.coffee:114
msgid "Failed to save the order of the checklist items. Please try again."
msgstr ""
@ -6178,7 +6170,7 @@ msgstr ""
msgid "Failed to set up QR code. Please try again."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:159
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:162
msgid "Failed to update checklist item."
msgstr ""
@ -7560,7 +7552,7 @@ msgstr ""
msgid "Incomplete Ticket Checklist"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklist.vue:26
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklist.vue:29
msgid "Incomplete checklist items"
msgstr ""
@ -7904,7 +7896,7 @@ msgstr ""
msgid "It is not possible to delete your current account."
msgstr ""
#: app/models/ticket.rb:275
#: app/models/ticket.rb:274
msgid "It is not possible to merge into an already merged ticket."
msgstr ""
@ -9267,7 +9259,7 @@ msgstr ""
#: app/assets/javascripts/app/models/checklist.coffee:7
#: app/assets/javascripts/app/models/checklist_item.coffee:7
#: app/assets/javascripts/app/models/checklist_template.coffee:7
#: app/assets/javascripts/app/models/checklist_template_item.coffee:7
#: app/assets/javascripts/app/models/checklist_template_item.coffee:5
#: app/assets/javascripts/app/models/core_workflow.coffee:6
#: app/assets/javascripts/app/models/core_workflow_custom_module.coffee:4
#: app/assets/javascripts/app/models/group.coffee:7
@ -9708,7 +9700,7 @@ msgid "No certificate found."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_checklist_start.jst.eco:3
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:427
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:430
msgid "No checklist added to this ticket yet."
msgstr ""
@ -9866,7 +9858,7 @@ msgstr ""
msgid "No translation for this locale available"
msgstr ""
#: app/models/ticket.rb:397
#: app/models/ticket.rb:396
msgid "No triggers active"
msgstr ""
@ -10934,7 +10926,7 @@ msgstr ""
msgid "Please accept the %s."
msgstr ""
#: app/assets/javascripts/app/controllers/checklist_template.coffee:48
#: app/assets/javascripts/app/controllers/checklist_template.coffee:55
msgid "Please add at least one item to the checklist."
msgstr ""
@ -11573,7 +11565,7 @@ msgid "Remove authenticator app"
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:22
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:373
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:376
msgid "Remove checklist"
msgstr ""
@ -11623,7 +11615,7 @@ msgid "Removes the shadows for a flat look."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:17
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:366
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:369
msgid "Rename checklist"
msgstr ""
@ -12959,11 +12951,11 @@ msgid "Show to user roles"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/TopBarHeader/TicketInformation/TicketInformationBadgeList/ReferencingTicketsBadgePopover.vue:54
msgid "Show tracked ticket"
msgid "Show tracking ticket"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/TopBarHeader/TicketInformation/TicketInformationBadgeList/ReferencingTicketsBadgePopover.vue:55
msgid "Show tracked tickets"
msgid "Show tracking tickets"
msgstr ""
#: app/assets/javascripts/app/views/integration/cti.jst.eco:95
@ -16081,14 +16073,11 @@ msgstr ""
msgid "Track retweets"
msgstr ""
#: app/assets/javascripts/app/lib/app_post/popover_provider/ticket_references_popover_provider.coffee:8
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/TopBarHeader/TicketInformation/TicketInformationBadgeList/ReferencingTicketsBadgePopover.vue:85
msgid "Tracked as checklist item in"
msgstr ""
#: app/assets/javascripts/app/lib/app_post/popover_provider/ticket_references_popover_provider.coffee:8
msgid "Tracked by checklist item by"
msgstr ""
#: app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco:27
msgid "Transfer conversation to another chat:"
msgstr ""
@ -16558,7 +16547,7 @@ msgstr ""
#: app/assets/javascripts/app/models/checklist.coffee:10
#: app/assets/javascripts/app/models/checklist_item.coffee:9
#: app/assets/javascripts/app/models/checklist_template.coffee:11
#: app/assets/javascripts/app/models/checklist_template_item.coffee:9
#: app/assets/javascripts/app/models/checklist_template_item.coffee:7
#: app/assets/javascripts/app/models/knowledge_base_answer_translation.coffee:8
#: app/assets/javascripts/app/models/organization.coffee:11
#: app/assets/javascripts/app/models/ticket.coffee:30
@ -17274,7 +17263,7 @@ msgstr ""
msgid "With checklist templates you can pre-fill your checklists."
msgstr ""
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:8
#: app/assets/javascripts/app/views/checklist_template/index.jst.eco:12
msgid "With checklists you can keep track of the progress of your ticket related tasks."
msgstr ""
@ -18837,7 +18826,7 @@ msgstr ""
msgid "recovery codes"
msgstr ""
#: app/models/checklist/item.rb:95
#: app/models/checklist/item.rb:103
msgid "reference must not be the checklist ticket."
msgstr ""

View file

@ -0,0 +1,78 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe SynchronizeChecklistItemStateFromTickets, :aggregate_failures, current_user_id: 1, type: :db_migration do
let!(:checklist) do
create(:checklist, ticket: create(:ticket), item_count: 1).tap do |checklist|
if another_ticket
checklist.items.last.update!(text: "Ticket##{another_ticket.number}", ticket_id: another_ticket.id)
end
checklist.items.last.update!(checked: checklist_item_checked)
checklist.reload
end
end
let(:checklist_item_checked) { true }
let(:another_ticket) { create(:ticket, state: Ticket::State.find_by(name: state_name)) }
let(:state_name) { 'open' }
context 'without linked ticket' do
let(:another_ticket) { nil }
context 'when checklist item is checked' do
it 'does not change the checklist item' do
expect(checklist.items.first.checked).to be(true)
expect { migrate }.not_to(change { checklist.items.first.checked })
end
end
context 'when checklist item is unchecked' do
let(:checklist_item_checked) { false }
it 'does not change the checklist item' do
expect(checklist.items.first.checked).to be(false)
expect { migrate }.not_to(change { checklist.items.first.checked })
end
end
end
context 'with a linked ticket' do
context 'when ticket is closed' do
let(:state_name) { 'closed' }
context 'when checklist item is checked' do
it 'does not change the checklist item' do
expect(checklist.items.first.checked).to be(true)
expect { migrate }.not_to(change { checklist.items.first.checked })
end
end
context 'when checklist item is unchecked' do
let(:checklist_item_checked) { false }
it 'checks the item' do
expect { migrate }.to change { checklist.items.first.checked }.from(false).to(true)
end
end
end
context 'when ticket is open' do
context 'when checklist item is checked' do
it 'unchecks the item' do
expect { migrate }.to change { checklist.items.first.checked }.from(true).to(false)
end
end
context 'when checklist item is unchecked' do
let(:checklist_item_checked) { false }
it 'does not change the checklist item' do
expect(checklist.items.first.checked).to be(false)
expect { migrate }.not_to(change { checklist.items.first.checked })
end
end
end
end
end

View file

@ -45,6 +45,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Add, current_user_id: 1, type:
end
before do
setup if defined?(setup)
checklist if defined?(checklist)
gql.execute(query, variables: variables)
end
@ -70,6 +71,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Add, current_user_id: 1, type:
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'creating the ticket checklist'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -24,6 +24,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Delete, current_user_id: 1, ty
let(:variables) { { checklistId: gql.id(checklist) } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
@ -42,6 +43,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Delete, current_user_id: 1, ty
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'deleting the ticket checklist'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -25,6 +25,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemDelete, current_user_id: 1
let(:variables) { { checklistId: gql.id(checklist), checklistItemId: gql.id(checklist_item) } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
@ -43,6 +44,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemDelete, current_user_id: 1
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'deleting the ticket checklist item'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -30,6 +30,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemOrderUpdate, current_user_
let(:variables) { { checklistId: gql.id(checklist), order: order } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
@ -48,6 +49,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemOrderUpdate, current_user_
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'updating the ticket checklist item order'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -29,6 +29,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemUpsert, current_user_id: 1
let(:variables) { { checklistId: gql.id(checklist), input: input } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
@ -60,6 +61,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::ItemUpsert, current_user_id: 1
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'creating the ticket checklist item'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'when providing both checked state and text' do
let(:input) { { 'checked' => true, 'text' => '' } }

View file

@ -28,6 +28,7 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::TitleUpdate, current_user_id:
let(:variables) { { checklistId: gql.id(checklist), title: title } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
@ -46,6 +47,14 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::TitleUpdate, current_user_id:
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'updating the ticket checklist title'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -32,6 +32,7 @@ RSpec.describe Gql::Queries::Checklist::Templates, current_user_id: 1, type: :gr
end
before do
setup if defined?(setup)
checklist_template
gql.execute(query, variables: variables)
end
@ -51,6 +52,14 @@ RSpec.describe Gql::Queries::Checklist::Templates, current_user_id: 1, type: :gr
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'returning template data'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without agent permissions', authenticated_as: :customer do
let(:customer) { create(:customer) }

View file

@ -61,6 +61,7 @@ RSpec.describe Gql::Queries::Ticket::Checklist, current_user_id: 1, type: :graph
end
before do
setup if defined?(setup)
checklist
gql.execute(query, variables: variables)
end
@ -80,6 +81,14 @@ RSpec.describe Gql::Queries::Ticket::Checklist, current_user_id: 1, type: :graph
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'returning checklist data'
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }

View file

@ -105,41 +105,41 @@ RSpec.describe Gql::Queries::Ticket, current_user_id: 1, type: :graphql do
context 'with an agent', authenticated_as: :agent do
context 'with permission' do
let(:agent) { create(:agent, groups: [ticket.group]) }
let(:base_expected_result) do
{
'id' => gql.id(ticket),
'internalId' => ticket.id,
'number' => ticket.number,
# Agent is allowed to see user data
'owner' => include(
'firstname' => ticket.owner.firstname,
'email' => ticket.owner.email,
'createdBy' => { 'internalId' => 1 },
'updatedBy' => { 'internalId' => 1 },
),
'tags' => %w[tag1 tag2],
'policy' => {
'agentReadAccess' => true,
'agentUpdateAccess' => true,
'createMentions' => true,
'destroy' => false,
'followUp' => true,
'update' => true
},
'stateColorCode' => 'open',
'checklist' => {
'name' => checklist.name
},
'referencingChecklistTickets' => [
{
'id' => gql.id(another_ticket)
}
]
}
end
let(:expected_result) { base_expected_result }
shared_examples 'finds the ticket' do
let(:expected_result) do
{
'id' => gql.id(ticket),
'internalId' => ticket.id,
'number' => ticket.number,
# Agent is allowed to see user data
'owner' => include(
'firstname' => ticket.owner.firstname,
'email' => ticket.owner.email,
'createdBy' => { 'internalId' => 1 },
'updatedBy' => { 'internalId' => 1 },
),
'tags' => %w[tag1 tag2],
'policy' => {
'agentReadAccess' => true,
'agentUpdateAccess' => true,
'createMentions' => true,
'destroy' => false,
'followUp' => true,
'update' => true
},
'stateColorCode' => 'open',
'checklist' => {
'name' => checklist.name
},
'referencingChecklistTickets' => [
{
'id' => gql.id(another_ticket)
}
]
}
end
it 'finds the ticket' do
expect(gql.result.data).to include(expected_result)
end
@ -169,6 +169,15 @@ RSpec.describe Gql::Queries::Ticket, current_user_id: 1, type: :graphql do
end
end
context 'with having checklist feature disabled' do
let(:setup) do
Setting.set('checklist', false)
end
let(:expected_result) { base_expected_result.merge({ 'checklist' => nil, 'referencingChecklistTickets' => nil }) }
include_examples 'finds the ticket'
end
context 'with having time accounting enabled' do
let(:ticket_time_accounting_types) { create_list(:ticket_time_accounting_type, 2) }
let(:ticket_time_accounting) { create(:ticket_time_accounting, ticket: ticket, time_unit: 50) }

View file

@ -22,6 +22,7 @@ RSpec.describe Gql::Subscriptions::Checklist::TemplateUpdates, current_user_id:
end
before do
setup if defined?(setup)
template if defined?(template)
gql.execute(subscription, variables: variables, context: { channel: mock_channel })
end
@ -37,6 +38,16 @@ RSpec.describe Gql::Subscriptions::Checklist::TemplateUpdates, current_user_id:
expect(gql.result.data).not_to be_nil
end
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it 'denies subscription with an error' do
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
end
end
it 'triggers after template create' do
template = create(:checklist_template)

View file

@ -30,6 +30,7 @@ RSpec.describe Gql::Subscriptions::Ticket::ChecklistUpdates, :aggregate_failures
end
before do
setup if defined?(setup)
gql.execute(subscription, variables: variables, context: { channel: mock_channel })
end
@ -44,6 +45,16 @@ RSpec.describe Gql::Subscriptions::Ticket::ChecklistUpdates, :aggregate_failures
expect(gql.result.data).not_to be_nil
end
context 'with disabled checklist feature' do
let(:setup) do
Setting.set('checklist', false)
end
it 'denies subscription with an error' do
expect(gql.result.error_type).to eq(Exceptions::Forbidden)
end
end
it 'triggers after checklist create' do
checklist = create(:checklist, ticket: ticket, item_count: nil)

View file

@ -56,6 +56,15 @@ RSpec.describe Ticket::TriggersSubscriptions do
.with(referenced_checklist, arguments: { ticket_id: referenced_checklist.ticket.to_global_id.to_s })
end
it 'triggers checklist update ticket is tracked in twice on state and group change' do
ticket.update state: Ticket::State.find_by(name: 'closed'), group: create(:group)
expect(Gql::Subscriptions::Ticket::ChecklistUpdates)
.to have_received(:trigger)
.with(referenced_checklist, arguments: { ticket_id: referenced_checklist.ticket.to_global_id.to_s })
.twice
end
it 'triggers checklist update once per checklist' do
ticket.update title: 'new title'

View file

@ -29,6 +29,14 @@ describe Checklist::ItemPolicy do
let(:user) { create(:agent, groups: [ticket.group]) }
it { is_expected.to permit_actions(:show, :create, :update, :destroy) }
context 'when checklist feature is disabled' do
before do
Setting.set('checklist', false)
end
it { is_expected.to forbid_actions(:show, :create, :update, :destroy) }
end
end
context 'when user has access to the ticket by having customer access' do

View file

@ -29,6 +29,14 @@ describe ChecklistPolicy do
let(:user) { create(:agent, groups: [ticket.group]) }
it { is_expected.to permit_actions(:show, :create, :update, :destroy) }
context 'when checklist feature is disabled' do
before do
Setting.set('checklist', false)
end
it { is_expected.to forbid_actions(:show, :create, :update, :destroy) }
end
end
context 'when user has access to the ticket by having customer access' do

View file

@ -18,6 +18,14 @@ describe ChecklistTemplatePolicy do
let(:user) { create(:admin) }
it { is_expected.to permit_actions(:show, :create, :update, :destroy) }
context 'when checklist feature is disabled' do
before do
Setting.set('checklist', false)
end
it { is_expected.to forbid_actions(:show, :create, :update, :destroy) }
end
end
context 'when user is a customer' do

View file

@ -17,22 +17,20 @@ RSpec.describe 'Checklist Item', authenticated_as: :agent_1, current_user_id: 1,
checklist_2
end
it 'does list checklist items', :aggregate_failures do
get '/api/v1/checklist_items', params: {}, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to include(hash_including('id' => checklist_1.items.first.id))
expect(json_response).not_to include(hash_including('id' => checklist_2.items.first.id))
end
it 'does show checklist items', :aggregate_failures do
get "/api/v1/checklist_items/#{checklist_1.items.first.id}", params: {}, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to include('id' => checklist_1.items.first.id)
end
it 'does not show checklist items' do
it 'does not show inaccessible checklist items' do
get "/api/v1/checklist_items/#{checklist_2.items.first.id}", params: {}, as: :json
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:forbidden)
end
it 'does not show nonexistant checklist items' do
get '/api/v1/checklist_items/1234', params: {}, as: :json
expect(response).to have_http_status(:forbidden)
end
it 'does create checklist items', :aggregate_failures do

View file

@ -19,22 +19,20 @@ RSpec.describe 'Checklist', authenticated_as: :agent_1, current_user_id: 1, type
checklist_2
end
it 'does list checklists', :aggregate_failures do
get '/api/v1/checklists', params: {}, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to include(hash_including('id' => checklist_1.id))
expect(json_response).not_to include(hash_including('id' => checklist_2.id))
end
it 'does show checklist', :aggregate_failures do
get "/api/v1/checklists/#{checklist_1.id}", params: {}, as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to include('id' => checklist_1.id)
end
it 'does not show checklist' do
it 'does not show inaccessible checklist' do
get "/api/v1/checklists/#{checklist_2.id}", params: {}, as: :json
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:forbidden)
end
it 'does not show nonexistant checklist' do
get '/api/v1/checklists/1234', params: {}, as: :json
expect(response).to have_http_status(:forbidden)
end
it 'does create checklist', :aggregate_failures do

View file

@ -1,88 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Checklist Items', authenticated_as: :agent, current_user_id: 1, type: :request do
let(:group) { create(:group) }
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => %w[read change] }) }
let(:ticket) { create(:ticket, group: group) }
describe '#create' do
let(:checklist) { create(:checklist, ticket: ticket) }
let(:checklist_item_params) do
{
text: 'foobar',
}
end
before do
checklist
post "/api/v1/tickets/#{ticket.id}/checklist/items", params: checklist_item_params
end
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
expect(response).to have_http_status(:ok)
end
end
end
describe '#update' do
let(:checklist) { create(:checklist, ticket: ticket) }
let(:checklist_item_params) do
{
checked: true,
}
end
before do
put "/api/v1/tickets/#{ticket.id}/checklist/items/#{checklist.items.last.id}", params: checklist_item_params
end
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
expect(response).to have_http_status(:ok)
end
end
end
describe '#destroy' do
let(:checklist) { create(:checklist, ticket: ticket) }
before do
delete "/api/v1/tickets/#{ticket.id}/checklist/items/#{checklist.items.last.id}"
end
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
expect(response).to have_http_status(:ok)
end
end
end
end

View file

@ -1,114 +0,0 @@
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Checklist', authenticated_as: :agent, current_user_id: 1, type: :request do
let(:group) { create(:group) }
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => %w[read change] }) }
let(:ticket) { create(:ticket, group: group) }
describe '#show' do
let(:checklist) { create(:checklist, ticket: ticket) }
before do
checklist
end
context 'when user is not authorized' do
let(:agent) { create(:agent) }
it 'returns forbidden status' do
get "/api/v1/tickets/#{ticket.id}/checklist", as: :json
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
get "/api/v1/tickets/#{ticket.id}/checklist"
expect(response).to have_http_status(:ok)
end
end
end
describe '#create', current_user_id: 1 do
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
post "/api/v1/tickets/#{ticket.id}/checklist"
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
post "/api/v1/tickets/#{ticket.id}/checklist"
expect(response).to have_http_status(:ok)
end
context 'when ticket already has a checklist' do
before do
create(:checklist, ticket:)
end
it 'returns unprocessable entity', aggregate_failures: true do
post "/api/v1/tickets/#{ticket.id}/checklist", as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
describe '#update' do
let(:checklist) { create(:checklist, ticket: ticket) }
let(:checklist_params) do
{
name: 'foobar',
}
end
before do
checklist
put "/api/v1/tickets/#{ticket.id}/checklist", params: checklist_params
end
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
expect(response).to have_http_status(:ok)
end
end
end
describe '#destroy' do
let(:checklist) { create(:checklist, ticket: ticket) }
before do
checklist
delete "/api/v1/tickets/#{ticket.id}/checklist"
end
context 'when user is not authorized' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it 'returns forbidden status' do
expect(response).to have_http_status(:forbidden)
end
end
context 'when user is authorized' do
it 'returns ok status' do
expect(response).to have_http_status(:ok)
end
end
end
end

View file

@ -143,9 +143,11 @@ RSpec.describe 'Ticket zoom > Checklist', authenticated_as: :authenticate, curre
item_text = SecureRandom.uuid
find(".checklistShow tr[data-id='#{item.id}'] .js-input").fill_in with: item_text, fill_options: { clear: :backspace }
# simulate other users change
other_item_text = SecureRandom.uuid
checklist.items.create!(text: other_item_text, created_by: other_agent, updated_by: other_agent)
# simulate other users change
UserInfo.with_user_id(other_agent.id) do
checklist.items.create!(text: other_item_text)
end
# not really another way to be absolutely sure that this works
sleep 5