Feature - Ticket Summary: Possibility to add generated next steps to checklists

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
This commit is contained in:
Benjamin Scharf
2025-04-09 15:17:15 +02:00
parent a77cc70b7b
commit d16d27c584
42 changed files with 1356 additions and 178 deletions

View File

@@ -883,5 +883,10 @@
"author": "Felix Niklas",
"url": "",
"license": "MIT"
}
},
"check2-square.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
}

View File

@@ -36,6 +36,7 @@ class SidebarChecklist extends App.Controller
done: =>
@widget = new App.SidebarChecklistStart(el: @elSidebar, parentVC: @)
)
@ticket.checklist_id = undefined
)
renderActions: =>

View File

@@ -3,11 +3,12 @@ class SidebarTicketSummary extends App.Controller
{ key: 'problem', name: __('Customer Intent'), value: 'problem' },
{ key: 'conversation_summary', name: __('Conversation Summary'), value: 'conversation_summary' },
{ key: 'open_questions', name: __('Open Questions'), value: 'open_questions', type: 'list' },
{ key: 'suggestions', name: __('Suggested Next Steps'), value: 'suggestions', type: 'list' },
{ key: 'suggestions', name: __('Suggested Next Steps'), value: 'suggestions', feature: 'checklist', type: 'list' },
]
constructor: ->
super
@controllerBind('ui::ticket::summaryUpdate', @summaryLoaded)
@controllerBind('config_update', @configUpdate)
@@ -15,7 +16,6 @@ class SidebarTicketSummary extends App.Controller
if config.name is 'ai_assistance_ticket_summary_config'
@parent.triggerArticleSummaryUpdate()
summaryLoaded: (payload) =>
return if !@elSidebar
return if payload.ticket_id isnt @ticket.id
@@ -25,6 +25,9 @@ class SidebarTicketSummary extends App.Controller
getAvailableDisplayStructure: ->
config = App.Config.get('ai_assistance_ticket_summary_config')
if not App.Config.get('checklist')
@DISPLAY_STRUCTURE.find((item) -> item.key is 'suggestions').feature = undefined
@DISPLAY_STRUCTURE.filter((item) -> !(item.key of config) or config[item.key] is true)
sidebarItem: =>
@@ -39,6 +42,7 @@ class SidebarTicketSummary extends App.Controller
sidebarCallback: @sidebarCallback
sidebarActions: []
}
@item
shown: =>
@@ -47,16 +51,114 @@ class SidebarTicketSummary extends App.Controller
sidebarCallback: (el) =>
@elSidebar = el
createChecklist: (callback) =>
@ajax(
id: 'checklist_ticket_create_empty'
type: 'POST'
url: "#{@apiPath}/checklists"
data: JSON.stringify({ ticket_id: @ticket.id })
processData: true
success: callback
error: =>
@notify(
type: 'error'
msg: App.i18n.translateInline('Unable to create checklist.')
)
)
convertToChecklistItem: (event) =>
target = $(event.target).closest('.js-btn-add-checklist-item')
return if !target
text = $(target).data('content')
checklistId = @ticket.checklist_id
if !checklistId
@createChecklist((data) =>
@ticket.checklist_id = data.id
@createChecklistItem(data.id, text)
)
else
@createChecklistItem(checklistId, text)
createChecklistItem: (checklistId, text) =>
item = new App.ChecklistItem
item.checklist_id = checklistId
item.text = text
item.save(
done: =>
@notify(
type: 'success'
msg: App.i18n.translateInline('Checklist item successfully added.')
)
fail: =>
@notify(
type: 'error'
msg: App.i18n.translateInline('Unable to add checklist item.')
)
)
convertAllToChecklistItems: =>
checklistId = @ticket.checklist_id
itemData = _.map(@summaryItems.suggestions, (text) ->
text: text
checked: false
)
if !checklistId
checklist = new App.Checklist
checklist = @createChecklist((data) =>
@ticket.checklist_id = data.id
@createBulkChecklistItems(data.id, itemData)
)
else
@createBulkChecklistItems(checklistId, itemData)
openChecklist: =>
@elSidebar
.closest('.content')
.find(".tabsSidebar-tab[data-tab='checklist']:not(.active)")
.click()
createBulkChecklistItems: (checklistId, items) =>
@ajax(
id: 'checklist_ticket_create_bulk'
type: 'POST'
url: "#{@apiPath}/checklist_items/create_bulk"
processData: true
data: JSON.stringify(
checklist_id: checklistId
items: items
)
success: =>
@openChecklist()
error: =>
@notify(
type: 'error'
msg: App.i18n.translateInline('Unable to add all checklist items.')
)
)
showSummarization: (payload) =>
noSummaryPossible = payload?.data?.result && Object.values(payload.data.result).every((item) -> item == null)
return if not payload?.data
@summaryItems = payload.data.result
noSummaryPossible = @summaryItems and _.every(_.values(@summaryItems), (item) -> item is null)
summarization = $(App.view('ticket_zoom/sidebar_ticket_summary')(
data: payload?.data,
data: payload.data,
noSummaryPossible: noSummaryPossible
structure: @getAvailableDisplayStructure()
structure: @getAvailableDisplayStructure()
))
@elSidebar.html(summarization)
@elSidebar.find('.js-retry').on('click', @retrySummarization)
@elSidebar.find('.js-retry').off('click.retry').on('click.retry', @retrySummarization)
@elSidebar.find('.js-btn-add-checklist-item').off('click.convert').on('click.convert', @convertToChecklistItem)
@elSidebar.find('.js-btn-ticket-summary-add-all-to-checklist').off('click.convert_all').on('click.convert_all', @convertAllToChecklistItems)
retrySummarization: (e) =>
@preventDefaultAndStopPropagation(e)

View File

@@ -21,23 +21,48 @@
<% else if @data?.result: %>
<% for item in @structure: %>
<% if !_.isEmpty(@data.result[item.value]): %>
<h4><%- @T(item.name) %></h4>
<% if item.type is 'list': %>
<ul>
<% for localList in @data.result[item.value]: %>
<li><%= localList %></li>
<div class="ticket-summary-sidebar-item">
<h3><%- @T(item.name) %></h4>
<% if item.type is 'list': %>
<% if item?.feature is 'checklist': %>
<ul class="js-ticket-summary-list">
<% for localList in @data.result[item.value]: %>
<li class="ticket-summary-sidebar-list-item-action">
<span>
<%= localList %>
</span>
<button class="btn btn--secondary btn--action btn--small js-btn-add-checklist-item" data-content="<%= localList %>" aria-label="<%- @T('Add as checklist item') %>" type="button">
<%- @Icon 'checkmark' %>
<span><%- @T('Add') %></span>
</button>
</li>
<% end %>
</ul>
<% else: %>
<ul>
<% for localList in @data.result[item.value]: %>
<li><%= localList %></li>
<% end %>
</ul>
<% end %>
<% else: %>
<p><%= @data.result[item.value] %></p>
<% end %>
</ul>
<% else: %>
<p><%= @data.result[item.value] %><p/>
<% end %>
<% if item?.feature is 'checklist': %>
<button class="btn btn--secondary btn--action btn--small js-btn-ticket-summary-add-all-to-checklist" type="button">
<%- @Icon 'double-checkmark' %>
<span><%- @T('Add all to checklist') %></span>
</button>
<% end %>
</div>
<% end %>
<% end %>
<span class="text-muted text-small">
<%- @T('*Be sure to check AI-generated summaries for accuracy') %>
</div>
<p class="text-muted text-small">
<%- @T('*Be sure to check AI-generated summaries for accuracy') %>
</p>
<% else: %>
<p><%- @T('Zammad Smart Assist is generating the summary for you…') %><p/>
<p><%- @T('Zammad Smart Assist is generating the summary for you…') %></p>
<div class="loading-container">
<div class="loading icon"></div>
</div>

View File

@@ -6,6 +6,7 @@
.icon-bold { width: 12px; height: 12px; }
.icon-chain { width: 16px; height: 16px; }
.icon-chat { width: 24px; height: 24px; }
.icon-check2-square { width: 16px; height: 16px; }
.icon-checkbox-checked-readonly { width: 11px; height: 11px; }
.icon-checkbox-checked { width: 11px; height: 11px; }
.icon-checkbox-indeterminate { width: 11px; height: 11px; }

View File

@@ -7260,8 +7260,11 @@ a.list-group-item.active > .badge,
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1 1 150px;
* {
@include bidi-style(margin-right, 10px, margin-left, 0);
}
}
.icon {
@@ -7272,25 +7275,103 @@ a.list-group-item.active > .badge,
}
.ticket-ai-summary-buttons {
max-width: fit-content;
flex: 1 0 auto;
flex: 0 0 auto;
margin: auto;
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
.btn {
flex: 0 0 fit-content;
white-space: nowrap;
margin-left: 0;
&:not(:last-child) {
@include bidi-style(margin-right, 10px, margin-left, 0);
}
}
}
}
}
.sidebar[data-tab='summary'] {
hr {
height: 1px; /* +1px for the border */
background: rgb(0, 158, 198);
background: linear-gradient(
90deg,
rgba(0, 158, 198, 1) 0%,
rgba(232, 79, 131, 1) 100%
);
}
.sidebar-block {
margin-bottom: 0;
p:last-child {
margin-bottom: 0;
}
}
}
.ticket-summary-sidebar-item {
display: flex;
flex-direction: column;
margin-bottom: 16px;
ul {
margin-bottom: 0;
}
h3 {
margin-bottom: 16px;
}
p:first-of-type {
margin-top: 0;
}
p:last-of-type {
margin-bottom: 0;
}
.ticket-summary-sidebar-list-item-action {
margin-bottom: 16px;
}
.js-btn-add-checklist-item,
.js-btn-ticket-summary-add-all-to-checklist {
display: flex;
}
.js-btn-add-checklist-item {
margin-top: 4px;
.icon-checkmark {
fill: var(--button-primary-background);
}
}
.js-btn-ticket-summary-add-all-to-checklist {
align-self: flex-end;
}
}
.ticket-summary-sidebar-item:has(
.ticket-summary-sidebar-list-item-action
)::before {
content: '';
margin-bottom: 16px;
height: 1px;
background: rgb(0, 158, 198);
background: linear-gradient(
90deg,
rgba(0, 158, 198, 1) 0%,
rgba(232, 79, 131, 1) 100%
);
}
.internal-border {
padding: 5px;
border-radius: 5px;

View File

@@ -11,6 +11,18 @@ class ChecklistItemsController < ApplicationController
model_create_render(Checklist::Item, new_item_params)
end
def create_bulk
create_item_params = params.permit(:checklist_id, items: %i[text checked])
checklist = Checklist.find(params[:checklist_id])
created_items = create_item_params[:items].map do |item|
checklist.items.create!(item)
end
render json: { success: true, checklist_item_ids: created_items.map(&:id) }, status: :created
end
def update
model_update_render(Checklist::Item, existing_item_params)
end

View File

@@ -13,8 +13,10 @@ class ChecklistsController < ApplicationController
checklist = if params[:template_id].present?
template = ChecklistTemplate.find(params[:template_id])
Checklist.create_from_template!(ticket, template)
else
elsif params[:create_first_item]
Checklist.create_fresh!(ticket)
else
Checklist.create!(ticket:)
end
render json: { id: checklist.id, assets: checklist.assets({}) }, status: :created

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 8C2.5 4.96243 4.96243 2.5 8 2.5C9.00262 2.5 9.94117 2.76782 10.7496 3.2355C10.9887 3.37377 11.2945 3.29209 11.4328 3.05306C11.5711 2.81403 11.4894 2.50816 11.2504 2.36989C10.2938 1.81654 9.1831 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 7.72386 14.2761 7.5 14 7.5C13.7239 7.5 13.5 7.72386 13.5 8C13.5 11.0376 11.0376 13.5 8 13.5C4.96243 13.5 2.5 11.0376 2.5 8Z" />
<path d="M15.3536 3.35355C15.5488 3.15829 15.5488 2.84171 15.3536 2.64645C15.1583 2.45118 14.8417 2.45118 14.6464 2.64645L8 9.29289L5.35355 6.64645C5.15829 6.45118 4.84171 6.45118 4.64645 6.64645C4.45118 6.84171 4.45118 7.15829 4.64645 7.35355L7.64645 10.3536C7.84171 10.5488 8.15829 10.5488 8.35355 10.3536L15.3536 3.35355Z" />
</svg>

After

Width:  |  Height:  |  Size: 866 B

View File

@@ -65,7 +65,8 @@ const createNewChecklist = async (
return addNewChecklistMutation
.send({
...input,
ticketId: ticket.value.id,
createFirstItem: true,
ticketId: ticket.value!.id,
})
.then(() => {
if (options.focusLastItem)

View File

@@ -343,6 +343,7 @@ describe('TicketSidebarChecklist', () => {
const calls = await waitForTicketChecklistAddMutationCalls()
expect(calls.at(-1)?.variables).toEqual({
createFirstItem: true,
ticketId: convertToGraphQLId('Ticket', 1),
})
})

View File

@@ -1,7 +1,7 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
import { onBeforeUnmount, onMounted, useTemplateRef, computed } from 'vue'
import type { ObjectLike } from '#shared/types/utils.ts'
@@ -14,9 +14,10 @@ interface Props {
icon: string
entity?: ObjectLike
actions?: MenuItem[]
variant?: 'ai'
}
defineProps<Props>()
const props = defineProps<Props>()
const scrollPosition = defineModel<number>({
required: true,
@@ -25,6 +26,17 @@ const scrollPosition = defineModel<number>({
const scrollContainer = useTemplateRef('scroll-container')
const variantClass = computed(() =>
props.variant === 'ai'
? 'ai-stripe before:-bottom-3 before:absolute relative before:left:0 before:right-0 before:h-0.5'
: '',
)
const iconClass = computed(() =>
props.variant === 'ai'
? 'text-blue-800'
: 'text-stone-200 dark:text-neutral-500',
)
// Handle scroll position (re)storing of the active sidebar, when navigating between taskbar tabs.
useScrollPosition(scrollContainer)
@@ -45,9 +57,10 @@ onBeforeUnmount(() => {
<CommonLabel
tag="h2"
class="min-h-7 grow gap-1.5"
:class="variantClass"
size="large"
:prefix-icon="icon"
icon-color="text-stone-200 dark:text-neutral-500"
:icon-color="iconClass"
>
{{ $t(title) }}
</CommonLabel>

View File

@@ -1,6 +1,7 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { ref, effectScope, watch, type EffectScope, computed } from 'vue'
import { useTicketArticleUpdatesSubscription } from '#shared/entities/ticket/graphql/subscriptions/ticketArticlesUpdates.api.ts'
@@ -17,9 +18,13 @@ import { useSessionStore } from '#shared/stores/session.ts'
import { useReactivate } from '#desktop/composables/useReactivate.ts'
import TicketSidebarSummaryContent from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue'
import {
type SummaryConfig,
type SummaryItem,
TicketSummaryFeature,
} from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/types.ts'
import { usePersistentStates } from '#desktop/pages/ticket/composables/usePersistentStates.ts'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { useTicketSummary } from '#desktop/pages/ticket/composables/useTicketSummary.ts'
import { useTicketAiAssistanceSummarizeMutation } from '#desktop/pages/ticket/graphql/mutations/ticketAIAssistanceSummarize.api.ts'
import { useTicketAiAssistanceSummaryUpdatesSubscription } from '#desktop/pages/ticket/graphql/subscriptions/ticketAIAssistanceSummaryUpdates.api.ts'
import type {
@@ -32,7 +37,8 @@ import TicketSidebarWrapper from '../TicketSidebarWrapper.vue'
defineProps<TicketSidebarProps>()
const { user, hasPermission } = useSessionStore()
const { config } = useApplicationStore()
const { config } = storeToRefs(useApplicationStore())
const { persistentStates } = usePersistentStates()
@@ -40,7 +46,52 @@ const emit = defineEmits<TicketSidebarEmits>()
const { ticketId } = useTicketInformation()
const { isEnabled, isProviderConfigured } = useTicketSummary()
const summaryConfig = computed(
() => config.value.ai_assistance_ticket_summary_config as SummaryConfig,
)
const isProviderConfigured = computed(() => !!config.value.ai_provider)
const { ticket } = useTicketInformation()
const isEnabled = computed(
() =>
!!(
ticket.value &&
ticket.value?.state.name !== 'merged' &&
config.value.ai_assistance_ticket_summary
),
)
const headings = computed<SummaryItem[]>(() => [
{
key: 'problem',
label: __('Customer Intent'),
active: true,
},
{
key: 'conversationSummary',
label: __('Conversation Summary'),
active: true,
},
{
key: 'openQuestions',
label: __('Open Questions'),
active: summaryConfig.value.open_questions,
},
{
key: 'suggestions',
label: __('Suggested Next Steps'),
active: summaryConfig.value.suggestions,
feature: config.value.checklist
? TicketSummaryFeature.Checklist
: undefined,
},
])
const summaryHeadings = computed(() =>
headings.value.filter((heading) => heading.active),
)
const summary = ref<TicketAiAssistanceSummary | null>(null)
const generationError = ref<AsyncExecutionError | null>(null)
@@ -64,6 +115,12 @@ const getAIAssistanceSummary = () => {
})
}
const retrySummaryGeneration = () => {
summary.value = null
generationError.value = null
getAIAssistanceSummary()
}
const activateSubscription = () => {
const articleSubscription = new SubscriptionHandler(
useTicketArticleUpdatesSubscription(
@@ -90,7 +147,7 @@ const activateSubscription = () => {
useTicketAiAssistanceSummaryUpdatesSubscription(
{
ticketId: ticketId.value,
locale: user?.preferences?.locale || config.locale_default,
locale: user?.preferences?.locale || config.value.locale_default,
},
() => ({
enabled: isProviderConfigured.value,
@@ -160,9 +217,11 @@ watch(
:context="context"
:sidebar-plugin="sidebarPlugin"
:summary="summary"
:summary-headings="summaryHeadings"
:is-provider-configured="isProviderConfigured"
:error="generationError"
:show-error-details="showErrorDetails"
@retry-get-summary="getAIAssistanceSummary"
@retry-get-summary="retrySummaryGeneration"
/>
</TicketSidebarWrapper>
</template>

View File

@@ -12,13 +12,20 @@ import type { ObjectLike } from '#shared/types/utils.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import TicketSidebarContent from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarContent.vue'
import SummarySkeleton from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary/SummarySkeleton.vue'
import { useTicketSummary } from '#desktop/pages/ticket/composables/useTicketSummary.ts'
import TicketSummaryCreateChecklist from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue'
import TicketSummaryItem from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryItem.vue'
import {
type SummaryItem,
TicketSummaryFeature,
} from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/types.ts'
import type { TicketSidebarContentProps } from '#desktop/pages/ticket/types/sidebar.ts'
interface Props extends TicketSidebarContentProps {
summary: Maybe<TicketAiAssistanceSummary>
error: Maybe<AsyncExecutionError>
showErrorDetails: boolean
summaryHeadings: SummaryItem[]
isProviderConfigured: boolean
}
const props = defineProps<Props>()
@@ -29,8 +36,6 @@ defineEmits<{
const persistentStates = defineModel<ObjectLike>({ required: true })
const { summaryHeadings, isProviderConfigured } = useTicketSummary()
const errorMessage = computed(() => props.error?.message)
const noSummaryPossible = computed(() => {
@@ -38,7 +43,7 @@ const noSummaryPossible = computed(() => {
if (!summary) return false
return summaryHeadings.value.every((section) => !summary[section.key]?.length)
return props.summaryHeadings.every((section) => !summary[section.key]?.length)
})
</script>
@@ -47,8 +52,9 @@ const noSummaryPossible = computed(() => {
v-model="persistentStates.scrollPosition"
:icon="sidebarPlugin.icon"
:title="sidebarPlugin.title"
variant="ai"
>
<section class="space-y-6">
<section class="space-y-4.5">
<template v-if="!isProviderConfigured">
<CommonAlert class="self-stretch" variant="danger">
<div class="flex flex-col gap-1.5">
@@ -93,35 +99,25 @@ const noSummaryPossible = computed(() => {
{{ $t('There is not enough content yet to summarize this ticket.') }}
</CommonAlert>
</template>
<template v-else-if="props.summary">
<template v-for="heading in summaryHeadings" :key="heading.key">
<article v-if="props.summary[heading.key]?.length">
<CommonLabel
class="mb-3 block! text-black! dark:text-white!"
tag="h3"
>{{ heading.label }}
</CommonLabel>
<ol
v-if="Array.isArray(props.summary[heading.key])"
class="space-y-1 text-gray-100 dark:text-neutral-400"
>
<li
v-for="content in props.summary[heading.key]"
:key="content"
class="flex gap-2 ps-2 before:mt-1.5 before:h-[3px] before:w-[3px] before:shrink-0 before:rounded-full before:bg-current"
>
<CommonLabel tag="p">{{ content }}</CommonLabel>
</li>
</ol>
<CommonLabel v-else tag="p"
>{{ props.summary[heading.key] }}
</CommonLabel>
<template v-else-if="summary">
<template v-for="item in summaryHeadings" :key="item.key">
<article v-if="summary[item.key]?.length">
<TicketSummaryCreateChecklist
v-if="item.feature === TicketSummaryFeature.Checklist"
:summary="summary[item.key] as string[]"
:label="item.label"
/>
<TicketSummaryItem
v-else
:summary="summary[item.key]!"
:label="item.label"
/>
</article>
</template>
<CommonLabel
size="small"
class="text-stone-200! dark:text-neutral-500!"
class="border-t border-neutral-100 pt-2 text-stone-200! dark:border-gray-900 dark:text-neutral-500!"
tag="p"
>{{ $t('*Be sure to check AI-generated summaries for accuracy') }}
</CommonLabel>

View File

@@ -0,0 +1,122 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
import {
NotificationTypes,
useNotifications,
} from '#shared/components/CommonNotifications/index.ts'
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import TicketSummaryItem from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryItem.vue'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { useTicketSidebar } from '#desktop/pages/ticket/composables/useTicketSidebar.ts'
import { useTicketChecklistAddMutation } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistAdd.api.ts'
import { useTicketChecklistItemsAddMutation } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistItemsAdd.api.ts'
import { useTicketChecklistItemUpsertMutation } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistItemUpsert.api.ts'
interface Props {
summary: string[]
label: string
}
const props = defineProps<Props>()
const { ticket } = useTicketInformation()
const ticketChecklist = computed(() => ticket.value?.checklist)
const sidebar = useTicketSidebar()
const { notify } = useNotifications()
const addNewChecklistHandler = new MutationHandler(
useTicketChecklistAddMutation(),
)
const ticketChecklistUpsertMutation = new MutationHandler(
useTicketChecklistItemUpsertMutation(),
)
const createNewChecklist = async () => {
const response = await addNewChecklistHandler.send({
ticketId: ticket.value!.id,
})
return response?.ticketChecklistAdd?.checklist
}
const createNewChecklistItem = async (text: string) => {
const checklist = ticketChecklist.value || (await createNewChecklist())
if (!checklist) return
ticketChecklistUpsertMutation
.send({
checklistId: checklist.id,
input: { text, checked: false },
})
.then(() =>
notify({
id: 'checklist-item-create',
type: NotificationTypes.Success,
message: __('Checklist item successfully added.'),
}),
)
.catch(() =>
notify({
id: 'checklist-item-create-error',
type: NotificationTypes.Error,
message: __('Failed to add new checklist item.'),
}),
)
}
const ticketChecklistItemsAddHandler = new MutationHandler(
useTicketChecklistItemsAddMutation(),
)
const convertAllToChecklistItems = async () => {
const checklist = ticketChecklist.value || (await createNewChecklist())
if (!checklist) return
const input = props.summary.map((text) => ({ text, checked: false }))
return ticketChecklistItemsAddHandler
.send({ checklistId: checklist.id, input })
.then(() => sidebar.switchSidebar('checklist'))
.catch(() =>
notify({
id: 'checklist-item-create-error',
type: NotificationTypes.Error,
message: __('Failed to add new checklist items.'),
}),
)
}
</script>
<template>
<TicketSummaryItem :label="label" :summary="summary" variant="ai">
<template #item-trailing="{ content }">
<CommonButton
prefix-icon="check2-circle"
class="col-span-2"
:aria-label="$t('Add as checklist item')"
@click="createNewChecklistItem(content)"
>
{{ $t('Add') }}
</CommonButton>
</template>
<template #trailing>
<CommonButton
prefix-icon="check2-all-circle"
class="mt-0.5 self-end"
@click="convertAllToChecklistItems"
>{{ $t('Add all to checklist') }}</CommonButton
>
</template>
</TicketSummaryItem>
</template>

View File

@@ -0,0 +1,41 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
summary: string | string[]
label: string
variant?: 'ai'
}
const props = defineProps<Props>()
const variantClass = computed(() =>
props.variant ? ['ai-stripe before:mb-3'] : [],
)
</script>
<template>
<div class="flex flex-col" :class="variantClass">
<CommonLabel class="mb-3 block! text-black! dark:text-white!" tag="h3"
>{{ label }}
</CommonLabel>
<ol
v-if="Array.isArray(summary)"
class="space-y-3 text-gray-100 dark:text-neutral-400"
>
<li
v-for="content in summary"
:key="content"
class="grid grid-cols-[min-content_1fr] gap-x-2 gap-y-1 ps-2 before:col-start-1 before:mt-2 before:h-[3px] before:w-[3px] before:shrink-0 before:rounded-full before:bg-current"
>
<CommonLabel class="col-2" tag="p">{{ content }}</CommonLabel>
<slot name="item-trailing" :content="content" />
</li>
</ol>
<CommonLabel v-else tag="p">{{ summary }} </CommonLabel>
<slot name="trailing" />
</div>
</template>

View File

@@ -0,0 +1,228 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { ref } from 'vue'
import { renderComponent } from '#tests/support/components/index.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import TicketSummaryCreateChecklist from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue'
import { TICKET_KEY } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { TICKET_SIDEBAR_SYMBOL } from '#desktop/pages/ticket/composables/useTicketSidebar.ts'
import {
mockTicketChecklistAddMutation,
waitForTicketChecklistAddMutationCalls,
} from '#desktop/pages/ticket/graphql/mutations/ticketChecklistAdd.mocks.ts'
import { waitForTicketChecklistItemsAddMutationCalls } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistItemsAdd.mocks.ts'
import { waitForTicketChecklistItemUpsertMutationCalls } from '#desktop/pages/ticket/graphql/mutations/ticketChecklistItemUpsert.mocks.ts'
const ticket = createDummyTicket({ checklist: null })
const switchSidebar = vi.fn()
const summary = ['Test checklist item A', 'Test checklist item B']
const renderTicketSummaryCreateChecklist = (
summaryArg = summary,
ticketArg = ticket,
) =>
renderComponent(TicketSummaryCreateChecklist, {
props: {
summary: summaryArg,
label: 'Checklist heading',
},
provide: [
[
TICKET_KEY,
{
ticket: ref(ticketArg),
ticketId: ref(ticketArg.id),
ticketInternalId: ref(ticketArg.internalId),
},
],
[
TICKET_SIDEBAR_SYMBOL,
{
switchSidebar,
},
],
],
})
describe('TicketSummaryCreateChecklist', () => {
it('render correctly', () => {
const wrapper = renderTicketSummaryCreateChecklist()
expect(wrapper.getByRole('heading', { level: 3 })).toHaveTextContent(
'Checklist heading',
)
expect(
wrapper.getAllByRole('button', { name: 'Add as checklist item' }).length,
).toBe(2)
})
it('converts summary item to checklist item if checklist is NOT available', async () => {
const wrapper = renderTicketSummaryCreateChecklist()
const buttons = wrapper.getAllByRole('button', {
name: 'Add as checklist item',
})
await wrapper.events.click(buttons[0])
const mockedChecklistId = convertToGraphQLId('Checklist', 3)
const mockedChecklistItem = {
id: convertToGraphQLId('Checklist::Item', 1),
text: '',
}
mockTicketChecklistAddMutation({
ticketChecklistAdd: {
checklist: {
name: '',
id: mockedChecklistId,
items: [mockedChecklistItem],
},
},
})
const calls = await waitForTicketChecklistAddMutationCalls()
expect(calls.at(-1)?.variables).toEqual({
ticketId: ticket.id,
})
const upsertCalls = await waitForTicketChecklistItemUpsertMutationCalls()
expect(upsertCalls.at(-1)?.variables).toEqual({
checklistId: expect.any(String), // mocker does not return mockedChecklistId
input: {
text: summary[0],
checked: false,
},
})
})
it('converts summary item to checklist item if checklist is available', async () => {
const checklistId = convertToGraphQLId('Checklist', 23)
const wrapper = renderTicketSummaryCreateChecklist(summary, {
...ticket,
checklist: {
id: checklistId,
incomplete: 3,
completed: false,
complete: 0,
total: 3,
},
})
const buttons = wrapper.getAllByRole('button', {
name: 'Add as checklist item',
})
await wrapper.events.click(buttons[0])
const calls = await waitForTicketChecklistItemUpsertMutationCalls()
expect(calls.at(-1)?.variables).toEqual({
checklistId,
input: {
text: summary[0],
checked: false,
},
})
// :TODO if we plan to initialize CommonNotifications as an option with renderComponent
// expect(
// await wrapper.findByRole('alert', {
// name: 'Checklist item successfully added.',
// }),
// ).toBeInTheDocument()
})
it('converts all summaries to checklist items if list is NOT available', async () => {
const wrapper = renderTicketSummaryCreateChecklist()
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Add all to checklist' }),
)
const mockedChecklistId = convertToGraphQLId('Checklist', 3)
const mockedChecklistItem = {
id: convertToGraphQLId('Checklist::Item', 1),
text: '',
}
mockTicketChecklistAddMutation({
ticketChecklistAdd: {
checklist: {
name: '',
id: mockedChecklistId,
items: [mockedChecklistItem],
},
},
})
const calls = await waitForTicketChecklistAddMutationCalls()
expect(calls.at(-1)?.variables).toEqual({
ticketId: ticket.id,
})
const itemsAddCalls = await waitForTicketChecklistItemsAddMutationCalls()
expect(itemsAddCalls.at(-1)?.variables).toEqual({
checklistId: expect.any(String), // mocker does not return mockedChecklistId
input: [
{
text: summary[0],
checked: false,
},
{
text: summary[1],
checked: false,
},
],
})
})
it('converts all summaries to checklist items', async () => {
const checklistId = convertToGraphQLId('Checklist', 23)
const wrapper = renderTicketSummaryCreateChecklist(summary, {
...ticket,
checklist: {
id: checklistId,
completed: false,
incomplete: 3,
complete: 0,
total: 3,
},
})
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Add all to checklist' }),
)
const itemsAddCalls = await waitForTicketChecklistItemsAddMutationCalls()
expect(itemsAddCalls.at(-1)?.variables).toEqual({
checklistId,
input: [
{
text: summary[0],
checked: false,
},
{
text: summary[1],
checked: false,
},
],
})
expect(switchSidebar).toHaveBeenCalledWith('checklist')
})
})

View File

@@ -11,11 +11,16 @@ import { createDummyArticle } from '#shared/entities/ticket-article/__tests__/mo
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
import { convertToGraphQLId } from '#shared/graphql/utils.ts'
import { pluginFiles } from '#desktop/pages/ticket/components/TicketSidebar/plugins/index.ts'
import plugin from '#desktop/pages/ticket/components/TicketSidebar/plugins/ticket-summary.ts'
import TicketSidebarSummary from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary.vue'
import { ARTICLES_INFORMATION_KEY } from '#desktop/pages/ticket/composables/useArticleContext.ts'
import { TICKET_KEY } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
import { mockTicketAiAssistanceSummarizeMutation } from '#desktop/pages/ticket/graphql/mutations/ticketAIAssistanceSummarize.mocks.ts'
import { TICKET_SIDEBAR_SYMBOL } from '#desktop/pages/ticket/composables/useTicketSidebar.ts'
import {
mockTicketAiAssistanceSummarizeMutation,
waitForTicketAiAssistanceSummarizeMutationCalls,
} from '#desktop/pages/ticket/graphql/mutations/ticketAIAssistanceSummarize.mocks.ts'
const defaultTicket = createDummyTicket()
const testArticle = createDummyArticle({
@@ -72,6 +77,18 @@ const renderRenderTicketSidebarSummary = (
articlesQuery: { watchOnResult: vi.fn() },
},
],
[
TICKET_SIDEBAR_SYMBOL,
{
switchSidebar: vi.fn(),
shownSidebars: ref({ 'ticket-summary': true }),
activeSidebar: ref('ticket-summary'),
sidebarPlugins: pluginFiles,
hasSidebar: vi.fn(),
showSidebar: vi.fn(),
hideSidebar: vi.fn(),
},
],
],
global: {
stubs: {
@@ -301,4 +318,36 @@ describe('TicketSidebarSummary', () => {
wrapper.getAllByLabelText('Placeholder for AI generated text'),
).toHaveLength(16)
})
it('hides feature if feature flag is disabled', async () => {
mockApplicationConfig({
checklist: false,
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
suggestions: true,
},
})
mockTicketAiAssistanceSummarizeMutation({
ticketAIAssistanceSummarize: ticketAIAssistanceSummarizeMock,
})
const wrapper = renderRenderTicketSidebarSummary()
await waitForTicketAiAssistanceSummarizeMutationCalls()
expect(
wrapper.queryByRole('button', { name: 'Add all to checklist' }),
).not.toBeInTheDocument()
expect(
wrapper.queryAllByRole('button', { name: 'Add as checklist item' })
.length,
).toBe(0)
})
})

View File

@@ -2,9 +2,14 @@
import type { TicketAiAssistanceSummary } from '#shared/graphql/types.ts'
export enum TicketSummaryFeature {
Checklist = 'checklist',
}
export interface SummaryItem {
label: string
key: keyof TicketAiAssistanceSummary
feature?: TicketSummaryFeature
active: boolean
}

View File

@@ -1,61 +0,0 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useApplicationStore } from '#shared/stores/application.ts'
import type {
SummaryConfig,
SummaryItem,
} from '#desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/types.ts'
import { useTicketInformation } from '#desktop/pages/ticket/composables/useTicketInformation.ts'
export const useTicketSummary = () => {
const { config } = storeToRefs(useApplicationStore())
const summaryConfig = computed(
() => config.value.ai_assistance_ticket_summary_config as SummaryConfig,
)
const isProviderConfigured = computed(() => !!config.value.ai_provider)
const { ticket } = useTicketInformation()
const isEnabled = computed(
() =>
!!(
ticket.value &&
ticket.value?.state.name !== 'merged' &&
config.value.ai_assistance_ticket_summary
),
)
const headings = computed<SummaryItem[]>(() => [
{
key: 'problem',
label: __('Customer Intent'),
active: true,
},
{
key: 'conversationSummary',
label: __('Conversation Summary'),
active: true,
},
{
key: 'openQuestions',
label: __('Open Questions'),
active: summaryConfig.value.open_questions,
},
{
key: 'suggestions',
label: __('Suggested Next Steps'),
active: summaryConfig.value.suggestions,
},
])
const summaryHeadings = computed(() =>
headings.value.filter((heading) => heading.active),
)
return { isEnabled, isProviderConfigured, summaryHeadings }
}

View File

@@ -7,8 +7,12 @@ import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const TicketChecklistAddDocument = gql`
mutation ticketChecklistAdd($ticketId: ID!, $templateId: ID) {
ticketChecklistAdd(ticketId: $ticketId, templateId: $templateId) {
mutation ticketChecklistAdd($ticketId: ID!, $templateId: ID, $createFirstItem: Boolean) {
ticketChecklistAdd(
ticketId: $ticketId
templateId: $templateId
createFirstItem: $createFirstItem
) {
checklist {
id
name

View File

@@ -1,5 +1,5 @@
mutation ticketChecklistAdd($ticketId: ID!, $templateId: ID) {
ticketChecklistAdd(ticketId: $ticketId, templateId: $templateId) {
mutation ticketChecklistAdd($ticketId: ID!, $templateId: ID, $createFirstItem: Boolean) {
ticketChecklistAdd(ticketId: $ticketId, templateId: $templateId, createFirstItem: $createFirstItem) {
checklist {
id
name

View File

@@ -0,0 +1,18 @@
import * as Types from '#shared/graphql/types.ts';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const TicketChecklistItemsAddDocument = gql`
mutation ticketChecklistItemsAdd($checklistId: ID!, $input: [TicketChecklistItemInput!]!) {
ticketChecklistItemsAdd(checklistId: $checklistId, input: $input) {
success
}
}
`;
export function useTicketChecklistItemsAddMutation(options: VueApolloComposable.UseMutationOptions<Types.TicketChecklistItemsAddMutation, Types.TicketChecklistItemsAddMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.TicketChecklistItemsAddMutation, Types.TicketChecklistItemsAddMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.TicketChecklistItemsAddMutation, Types.TicketChecklistItemsAddMutationVariables>(TicketChecklistItemsAddDocument, options);
}
export type TicketChecklistItemsAddMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.TicketChecklistItemsAddMutation, Types.TicketChecklistItemsAddMutationVariables>;

View File

@@ -0,0 +1,5 @@
mutation ticketChecklistItemsAdd ($checklistId: ID!, $input: [TicketChecklistItemInput!]!){
ticketChecklistItemsAdd(checklistId: $checklistId, input: $input) {
success
}
}

View File

@@ -0,0 +1,17 @@
import * as Types from '#shared/graphql/types.ts';
import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './ticketChecklistItemsAdd.api.ts'
import * as ErrorTypes from '#shared/types/error.ts'
export function mockTicketChecklistItemsAddMutation(defaults: Mocks.MockDefaultsValue<Types.TicketChecklistItemsAddMutation, Types.TicketChecklistItemsAddMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.TicketChecklistItemsAddDocument, defaults)
}
export function waitForTicketChecklistItemsAddMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.TicketChecklistItemsAddMutation>(Operations.TicketChecklistItemsAddDocument)
}
export function mockTicketChecklistItemsAddMutationError(message: string, extensions: {type: ErrorTypes.GraphQLErrorTypes }) {
return Mocks.mockGraphQLResultWithError(Operations.TicketChecklistItemsAddDocument, message, extensions);
}

View File

@@ -252,6 +252,19 @@
@apply after:ms-1 after:inline-block after:font-extrabold after:text-yellow-600 after:content-['•'];
}
@utility ai-stripe {
&::before {
content: '';
height: 1px;
width: 100%;
background-image: repeating-linear-gradient(
to right,
var(--color-blue-800),
var(--color-pink-200)
);
}
}
/* Uses to be in the @layer utilities */
@media print {
[data-print-mode='true'] body {

View File

@@ -63,6 +63,7 @@
--color-red-600: #86270c;
--color-red-900: #220c06;
--color-pink-100: #efd9d2;
--color-pink-200: #d75b83;
--color-pink-300: #ea4d84;
--color-pink-500: #ff006b;

View File

@@ -1560,6 +1560,8 @@ export type Mutations = {
ticketChecklistItemOrderUpdate?: Maybe<TicketChecklistItemOrderUpdatePayload>;
/** Update or create a ticket checklist item. */
ticketChecklistItemUpsert?: Maybe<TicketChecklistItemUpsertPayload>;
/** Add checklist items. */
ticketChecklistItemsAdd?: Maybe<TicketChecklistItemsAddPayload>;
/** Update title of the ticket checklist. */
ticketChecklistTitleUpdate?: Maybe<TicketChecklistTitleUpdatePayload>;
/** Create a new ticket. */
@@ -1900,6 +1902,7 @@ export type MutationsTicketArticleRetrySecurityProcessArgs = {
/** All available mutations */
export type MutationsTicketChecklistAddArgs = {
createFirstItem?: InputMaybe<Scalars['Boolean']['input']>;
templateId?: InputMaybe<Scalars['ID']['input']>;
ticketId: Scalars['ID']['input'];
};
@@ -1933,6 +1936,13 @@ export type MutationsTicketChecklistItemUpsertArgs = {
};
/** All available mutations */
export type MutationsTicketChecklistItemsAddArgs = {
checklistId: Scalars['ID']['input'];
input: Array<TicketChecklistItemInput>;
};
/** All available mutations */
export type MutationsTicketChecklistTitleUpdateArgs = {
checklistId: Scalars['ID']['input'];
@@ -4037,11 +4047,22 @@ export type TicketChecklistItemOrderUpdatePayload = {
export type TicketChecklistItemUpsertPayload = {
__typename?: 'TicketChecklistItemUpsertPayload';
/** Updated or created checklist item. */
checklistItem?: Maybe<ChecklistItem>;
checklistItem: ChecklistItem;
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
};
/** Autogenerated return type of TicketChecklistItemsAdd. */
export type TicketChecklistItemsAddPayload = {
__typename?: 'TicketChecklistItemsAddPayload';
/** Updated checklist. */
checklist: Checklist;
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** Was the mutation successful? */
success: Scalars['Boolean']['output'];
};
/** Autogenerated return type of TicketChecklistTitleUpdate. */
export type TicketChecklistTitleUpdatePayload = {
__typename?: 'TicketChecklistTitleUpdatePayload';
@@ -6171,6 +6192,7 @@ export type TicketAiAssistanceSummarizeMutation = { __typename?: 'Mutations', ti
export type TicketChecklistAddMutationVariables = Exact<{
ticketId: Scalars['ID']['input'];
templateId?: InputMaybe<Scalars['ID']['input']>;
createFirstItem?: InputMaybe<Scalars['Boolean']['input']>;
}>;
@@ -6206,7 +6228,15 @@ export type TicketChecklistItemUpsertMutationVariables = Exact<{
}>;
export type TicketChecklistItemUpsertMutation = { __typename?: 'Mutations', ticketChecklistItemUpsert?: { __typename?: 'TicketChecklistItemUpsertPayload', checklistItem?: { __typename?: 'ChecklistItem', id: string, text: string, checked: boolean, ticketReference?: { __typename?: 'TicketReference', ticket?: { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, stateColorCode: EnumTicketStateColorCode, state: { __typename?: 'TicketState', id: string, name: string } } | null } | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null, exception?: EnumUserErrorException | null }> | null } | null };
export type TicketChecklistItemUpsertMutation = { __typename?: 'Mutations', ticketChecklistItemUpsert?: { __typename?: 'TicketChecklistItemUpsertPayload', checklistItem: { __typename?: 'ChecklistItem', id: string, text: string, checked: boolean, ticketReference?: { __typename?: 'TicketReference', ticket?: { __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 TicketChecklistItemsAddMutationVariables = Exact<{
checklistId: Scalars['ID']['input'];
input: Array<TicketChecklistItemInput> | TicketChecklistItemInput;
}>;
export type TicketChecklistItemsAddMutation = { __typename?: 'Mutations', ticketChecklistItemsAdd?: { __typename?: 'TicketChecklistItemsAddPayload', success: boolean } | null };
export type TicketChecklistTitleUpdateMutationVariables = Exact<{
checklistId: Scalars['ID']['input'];

View File

@@ -6,20 +6,23 @@ module Gql::Mutations
argument :ticket_id, GraphQL::Types::ID, loads: Gql::Types::TicketType, description: 'Ticket to create the new checklist for.'
argument :template_id, GraphQL::Types::ID, required: false, description: 'Checklist template ID to base the ticket checklist on.'
argument :create_first_item, GraphQL::Types::Boolean, required: false, description: 'Create the first item in the checklist (only if no template is used).'
field :checklist, Gql::Types::ChecklistType, null: true, description: 'Created checklist'
def authorized?(ticket:, template_id: nil)
def authorized?(ticket:, create_first_item: false, template_id: nil)
Setting.get('checklist') && pundit_authorized?(ticket, :agent_update_access?)
end
def resolve(ticket:, template_id: nil)
def resolve(ticket:, create_first_item: false, template_id: nil)
checklist = if template_id
template = Gql::ZammadSchema.verified_object_from_id(template_id, type: ::ChecklistTemplate)
Checklist.create_from_template!(ticket, template)
else
elsif create_first_item
Checklist.create_fresh!(ticket)
else
Checklist.create!(ticket:)
end
{ checklist: }

View File

@@ -4,11 +4,11 @@ module Gql::Mutations
class Ticket::Checklist::ItemUpsert < Ticket::Checklist::Base
description 'Update or create a ticket checklist item.'
argument :checklist_id, GraphQL::Types::ID, required: true, loads: Gql::Types::ChecklistType, description: 'ID of the ticket checklist to update or create an item for.'
argument :checklist_id, GraphQL::Types::ID, loads: Gql::Types::ChecklistType, description: 'ID of the ticket checklist to update or create an item for.'
argument :checklist_item_id, GraphQL::Types::ID, required: false, loads: Gql::Types::Checklist::ItemType, description: 'ID of the ticket checklist item to update.'
argument :input, Gql::Types::Input::Ticket::Checklist::ItemInputType, required: true, description: 'Input field values of the ticket checklist item.'
argument :input, Gql::Types::Input::Ticket::Checklist::ItemInputType, description: 'Input field values of the ticket checklist item.'
field :checklist_item, Gql::Types::Checklist::ItemType, null: true, description: 'Updated or created checklist item.'
field :checklist_item, Gql::Types::Checklist::ItemType, null: false, description: 'Updated or created checklist item.'
def authorized?(checklist:, input:, checklist_item: nil)
if checklist_item

View File

@@ -0,0 +1,28 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module Gql::Mutations
class Ticket::Checklist::ItemsAdd < Ticket::Checklist::Base
description 'Add checklist items.'
argument :checklist_id, GraphQL::Types::ID, loads: Gql::Types::ChecklistType, description: 'ID of the ticket checklist to update or create an item for.'
argument :input, [Gql::Types::Input::Ticket::Checklist::ItemInputType], description: 'Input field values of the ticket checklist item.'
field :success, Boolean, null: false, description: 'Was the mutation successful?'
field :checklist, Gql::Types::ChecklistType, null: false, description: 'Updated checklist.'
def authorized?(checklist:, input:)
pundit_authorized?(Checklist::Item.new(checklist:), :create?)
end
def resolve(checklist:, input:)
input.each do |item_input|
checklist.items.create!(item_input.to_h)
end
{
success: true,
checklist: checklist.reload
}
end
end
end

View File

@@ -10010,6 +10010,16 @@
"ofType": null
},
"defaultValue": null
},
{
"name": "createFirstItem",
"description": "Create the first item in the checklist (only if no template is used).",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
}
],
"type": {
@@ -10188,6 +10198,55 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ticketChecklistItemsAdd",
"description": "Add checklist items.",
"args": [
{
"name": "checklistId",
"description": "ID of the ticket checklist to update or create an item for.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "input",
"description": "Input field values of the ticket checklist item.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TicketChecklistItemInput",
"ofType": null
}
}
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TicketChecklistItemsAddPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ticketChecklistTitleUpdate",
"description": "Update title of the ticket checklist.",
@@ -22229,9 +22288,13 @@
"description": "Updated or created checklist item.",
"args": [],
"type": {
"kind": "OBJECT",
"name": "ChecklistItem",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ChecklistItem",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
@@ -22262,6 +22325,69 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TicketChecklistItemsAddPayload",
"description": "Autogenerated return type of TicketChecklistItemsAdd.",
"fields": [
{
"name": "checklist",
"description": "Updated checklist.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Checklist",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "UserError",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "success",
"description": "Was the mutation successful?",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TicketChecklistTitleUpdatePayload",

View File

@@ -7,6 +7,10 @@ class Controllers::ChecklistItemsControllerPolicy < Controllers::ApplicationCont
.create?
end
def create_bulk?
create?
end
def show?
Checklist::ItemPolicy
.new(user, checklist_item)

View File

@@ -3,7 +3,9 @@
Zammad::Application.routes.draw do
scope Rails.configuration.api_path do
resources :checklists, only: %i[show create update destroy]
resources :checklist_items, only: %i[create update destroy show]
resources :checklist_items, only: %i[create update destroy show] do
post :create_bulk, on: :collection
end
resources :checklist_templates, only: %i[index show create update destroy]
end
end

View File

@@ -473,8 +473,8 @@ msgstr ""
msgid "**Service Level Agreements**, abbreviated **SLAs**, help you to meet specific response times for your customers' requests. This way you can define goals such as answering every inquiry within eight hours. If you are at risk of missing this target, Zammad will alert you.\n\nYou can define targets for three different metrics: **response time** (time between the creation of a ticket and the first reaction of an agent), **update time** (time between a customer's request and an agent's reaction), and **solution time** (time between creating and closing a ticket).\n\nAny escalated tickets (i.e. tickets that have missed the defined target) are displayed in a separate view in your overviews. You can also configure **email notifications**."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:37
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:126
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:62
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:122
msgid "*Be sure to check AI-generated summaries for accuracy"
msgstr ""
@@ -1008,7 +1008,9 @@ msgstr ""
#: app/assets/javascripts/app/views/profile/linked_accounts.jst.eco:15
#: app/assets/javascripts/app/views/tag/index.jst.eco:25
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_checklist/show.jst.eco:19
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:34
#: app/frontend/apps/desktop/components/Form/fields/FieldGroupPermissions/FieldGroupPermissionsInput.vue:278
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:106
#: app/frontend/shared/form/i18n/locales.ts:25
msgid "Add"
msgstr ""
@@ -1033,7 +1035,7 @@ msgstr ""
msgid "Add Certificate"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:452
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:453
msgid "Add Empty Checklist"
msgstr ""
@@ -1107,10 +1109,20 @@ msgstr ""
msgid "Add alarm to pending reminder and escalated tickets."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:54
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:118
msgid "Add all to checklist"
msgstr ""
#: app/frontend/shared/form/i18n/locales.ts:74
msgid "Add all values"
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:34
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:106
msgid "Add as checklist item"
msgstr ""
#: app/assets/javascripts/app/views/channel/form.jst.eco:112
msgid "Add attachment option to upload."
msgstr ""
@@ -2843,7 +2855,7 @@ msgstr ""
msgid "Check this box if you want to customise how options are sorted. If the box is not checked, values are sorted in alphabetical order."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:51
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:52
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/plugins/checklist.ts:11
msgid "Checklist"
msgstr ""
@@ -2866,6 +2878,11 @@ msgstr ""
msgid "Checklist Template items are limited to 100 items per checklist."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:93
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:65
msgid "Checklist item successfully added."
msgstr ""
#: app/models/checklist/item.rb:108
msgid "Checklist items are limited to 100 items per checklist."
msgstr ""
@@ -3484,7 +3501,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:34
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:4
#: app/frontend/apps/desktop/pages/ticket/composables/useTicketSummary.ts:41
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary.vue:74
msgid "Conversation Summary"
msgstr ""
@@ -4004,7 +4021,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:27
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:3
#: app/frontend/apps/desktop/pages/ticket/composables/useTicketSummary.ts:36
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary.vue:69
msgid "Customer Intent"
msgstr ""
@@ -6526,11 +6543,16 @@ msgstr ""
msgid "Failed Tasks"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:146
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:147
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:72
msgid "Failed to add new checklist item."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:167
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent/TicketSummaryCreateChecklist.vue:94
msgid "Failed to add new checklist items."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:168
msgid "Failed to delete checklist item."
msgstr ""
@@ -6548,7 +6570,7 @@ msgstr ""
msgid "Failed to roll back the migration of the channel!"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:153
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:154
msgid "Failed to save checklist order."
msgstr ""
@@ -6560,7 +6582,7 @@ msgstr ""
msgid "Failed to set up QR code. Please try again."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:160
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:161
msgid "Failed to update checklist item."
msgstr ""
@@ -10265,7 +10287,7 @@ msgid "No %s configured."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:15
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:58
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:64
msgid "No AI provider is currently set up. Please contact your administrator."
msgstr ""
@@ -10357,7 +10379,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:465
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:466
msgid "No checklist added to this ticket yet."
msgstr ""
@@ -10991,7 +11013,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:41
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:5
#: app/frontend/apps/desktop/pages/ticket/composables/useTicketSummary.ts:46
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary.vue:79
msgid "Open Questions"
msgstr ""
@@ -12430,7 +12452,7 @@ msgid "Remove authenticator app"
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:21
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:405
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:406
msgid "Remove checklist"
msgstr ""
@@ -12484,7 +12506,7 @@ msgid "Removes the shadows for a flat look."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee:16
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:398
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarChecklist/TicketSidebarChecklistContent.vue:399
msgid "Rename checklist"
msgstr ""
@@ -12730,7 +12752,7 @@ msgstr ""
#: app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfiguration/TwoFactorConfigurationSecurityKeys.vue:66
#: app/frontend/apps/desktop/pages/authentication/components/LoginTwoFactor.vue:177
#: app/frontend/apps/desktop/pages/authentication/views/AdminPasswordAuth.vue:140
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:87
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:93
#: app/frontend/apps/mobile/pages/authentication/components/LoginTwoFactor.vue:177
msgid "Retry"
msgstr ""
@@ -14511,11 +14533,11 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:47
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:6
#: app/frontend/apps/desktop/pages/ticket/composables/useTicketSummary.ts:51
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummary.vue:84
msgid "Suggested Next Steps"
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:38
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:41
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/plugins/ticket-summary.ts:11
msgid "Summary"
msgstr ""
@@ -15828,7 +15850,7 @@ msgstr ""
msgid "The start date must precede or match end date."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:72
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:78
msgid "The summary could not be generated. Please try again later or contact your administrator."
msgstr ""
@@ -16075,7 +16097,7 @@ msgid "There is no valid PGP email signer."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:19
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:93
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:99
msgid "There is not enough content yet to summarize this ticket."
msgstr ""
@@ -17535,6 +17557,18 @@ msgstr ""
msgid "URL (AJAX endpoint)"
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:140
msgid "Unable to add all checklist items."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:98
msgid "Unable to add checklist item."
msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket_summary.coffee:65
msgid "Unable to create checklist."
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:5
msgid "Unable to generate summary, try again later or contact your admin."
msgstr ""
@@ -19063,8 +19097,8 @@ msgstr ""
msgid "Zammad Helpdesk"
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:40
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:136
#: app/assets/javascripts/app/views/ticket_zoom/sidebar_ticket_summary.jst.eco:65
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSummary/TicketSidebarSummaryContent.vue:132
msgid "Zammad Smart Assist is generating the summary for you…"
msgstr ""

View File

@@ -33,6 +33,9 @@
</symbol><symbol id="icon-chat" viewBox="0 0 24 24">
<title>chat</title>
<path d="M2 5.006C2 3.898 2.898 3 3.99 3h16.02C21.108 3 22 3.897 22 5.006v9.988A2.003 2.003 0 0 1 20.01 17H3.99C2.892 17 2 16.103 2 14.994V5.006ZM12 17l6 4v-4h-6Z" fill-rule="evenodd"/>
</symbol><symbol id="icon-check2-square" viewBox="0 0 16 16">
<path d="M3 14.5A1.5 1.5 0 0 1 1.5 13V3A1.5 1.5 0 0 1 3 1.5h8a.5.5 0 0 1 0 1H3a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V8a.5.5 0 0 1 1 0v5a1.5 1.5 0 0 1-1.5 1.5H3Z"/>
<path d="m8.354 10.354 7-7a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0Z"/>
</symbol><symbol id="icon-checkbox-checked-readonly" viewBox="0 0 11 11">
<title>checkbox-checked-readonly</title>
<g fill-rule="evenodd">

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path id="check2-square" d="M3 14.5C2.17157 14.5 1.5 13.8284 1.5 13V3C1.5 2.17157 2.17157 1.5 3 1.5H11C11.2761 1.5 11.5 1.72386 11.5 2C11.5 2.27614 11.2761 2.5 11 2.5H3C2.72386 2.5 2.5 2.72386 2.5 3V13C2.5 13.2761 2.72386 13.5 3 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V8C13.5 7.72386 13.7239 7.5 14 7.5C14.2761 7.5 14.5 7.72386 14.5 8V13C14.5 13.8284 13.8284 14.5 13 14.5H3Z" />
<path d="M8.35355 10.3536L15.3536 3.35355C15.5488 3.15829 15.5488 2.84171 15.3536 2.64645C15.1583 2.45118 14.8417 2.45118 14.6464 2.64645L8 9.29289L5.35355 6.64645C5.15829 6.45118 4.84171 6.45118 4.64645 6.64645C4.45118 6.84171 4.45118 7.15829 4.64645 7.35355L7.64645 10.3536C7.84171 10.5488 8.15829 10.5488 8.35355 10.3536Z" />
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -9,8 +9,8 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Add, current_user_id: 1, type:
let(:query) do
<<~QUERY
mutation ticketChecklistAdd($ticketId: ID!, $templateId: ID) {
ticketChecklistAdd(ticketId: $ticketId, templateId: $templateId) {
mutation ticketChecklistAdd($ticketId: ID!, $createFirstItem: Boolean, $templateId: ID) {
ticketChecklistAdd(ticketId: $ticketId, createFirstItem: $createFirstItem, templateId: $templateId) {
checklist {
id
name
@@ -28,7 +28,8 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Add, current_user_id: 1, type:
QUERY
end
let(:variables) { { ticketId: gql.id(ticket) } }
let(:create_first_item) { true }
let(:variables) { { ticketId: gql.id(ticket), createFirstItem: create_first_item } }
let(:response) do
{
@@ -69,7 +70,23 @@ RSpec.describe Gql::Mutations::Ticket::Checklist::Add, current_user_id: 1, type:
end
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'creating the ticket checklist'
context 'with create_first_item' do
it_behaves_like 'creating the ticket checklist'
end
context 'without create_first_item' do
let(:create_first_item) { false }
let(:response) do
{
'id' => a_kind_of(String),
'name' => '',
'items' => [],
}
end
it_behaves_like 'creating the ticket checklist'
end
context 'with disabled checklist feature' do
let(:setup) do

View File

@@ -0,0 +1,109 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Gql::Mutations::Ticket::Checklist::ItemsAdd, current_user_id: 1, type: :graphql do
let(:group) { create(:group) }
let(:agent) { create(:agent, groups: [group]) }
let(:ticket) { create(:ticket, group: group) }
let(:checklist) { create(:checklist, ticket: ticket) }
let(:input) { [{ 'text' => 'foobar', 'checked' => true }] }
let(:query) do
<<~QUERY
mutation ticketChecklistItemsAdd($checklistId: ID!, $input: [TicketChecklistItemInput!]!) {
ticketChecklistItemsAdd(checklistId: $checklistId, input: $input) {
success
checklist {
items {
text
checked
}
}
errors {
message
}
}
}
QUERY
end
let(:variables) { { checklistId: gql.id(checklist), input: input } }
before do
setup if defined?(setup)
gql.execute(query, variables: variables)
end
shared_examples 'creating the ticket checklist items' do
it 'creates the ticket checklist items' do
expect(gql.result.data).to include(
'success' => true,
'checklist' => {
'items' => include(
{
'text' => variables[:input].first['text'],
'checked' => variables[:input].first['checked'] || false,
}
)
}
)
end
end
shared_examples 'raising an error' do |error_type|
it 'raises an error' do
expect(gql.result.error_type).to eq(error_type)
end
end
context 'with authenticated session', authenticated_as: :agent do
it_behaves_like 'creating the ticket checklist items'
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' => '' }] }
it_behaves_like 'creating the ticket checklist items'
end
context 'when providing text value only' do
let(:input) { [{ 'text' => 'foobar' }] }
it_behaves_like 'creating the ticket checklist items'
end
context 'without access to the ticket' do
let(:agent) { create(:agent) }
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'with ticket read permission' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => 'read' }) }
it_behaves_like 'raising an error', Exceptions::Forbidden
end
context 'with ticket read+change permissions' do
let(:agent) { create(:agent, groups: [group], group_names_access_map: { group.name => %w[read change] }) }
it_behaves_like 'creating the ticket checklist items'
end
context 'when ticket checklist does not exist' do
let(:variables) { { checklistId: 'gid://Zammad/Checklist/0', input: input } }
it_behaves_like 'raising an error', ActiveRecord::RecordNotFound
end
end
it_behaves_like 'graphql responds with error if unauthenticated'
end

View File

@@ -44,6 +44,17 @@ RSpec.describe 'Checklist Item', authenticated_as: :agent_1, current_user_id: 1,
expect(response).to have_http_status(:forbidden)
end
it 'does bulk create checklist items', :aggregate_failures do
post '/api/v1/checklist_items/create_bulk', params: { checklist_id: checklist_1.id, items: [{ text: SecureRandom.uuid }] }, as: :json
expect(response).to have_http_status(:created)
expect(json_response).to include('success' => true, 'checklist_item_ids' => [Checklist::Item.last.id])
end
it 'does not bulk create checklist items' do
post '/api/v1/checklist_items/create_bulk', params: { checklist_id: checklist_2.id, items: [{ text: SecureRandom.uuid }] }, as: :json
expect(response).to have_http_status(:forbidden)
end
it 'does update checklist items', :aggregate_failures do
put "/api/v1/checklist_items/#{checklist_1.items.first.id}", params: { id: checklist_1.items.first.id, text: SecureRandom.uuid }, as: :json
expect(response).to have_http_status(:ok)

View File

@@ -35,10 +35,18 @@ RSpec.describe 'Checklist', authenticated_as: :agent_1, current_user_id: 1, type
expect(response).to have_http_status(:forbidden)
end
it 'does create checklist', :aggregate_failures do
it 'does create checklist with create_first_item', :aggregate_failures do
post '/api/v1/checklists', params: { name: SecureRandom.uuid, ticket_id: ticket_1_empty.id, create_first_item: true }, as: :json
expect(response).to have_http_status(:created)
expect(json_response).to include('id' => Checklist.last.id)
expect(Checklist.last.items.count).to eq(1)
end
it 'does create checklist without create_first_item', :aggregate_failures do
post '/api/v1/checklists', params: { name: SecureRandom.uuid, ticket_id: ticket_1_empty.id }, as: :json
expect(response).to have_http_status(:created)
expect(json_response).to include('id' => Checklist.last.id)
expect(Checklist.last.items.count).to eq(0)
end
it 'does not create checklist' do

View File

@@ -8,10 +8,12 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
let(:article) { create(:ticket_article, ticket:) }
let(:ai_provider) { 'zammad_ai' }
let(:ai_assistance_ticket_summary) { true }
let(:checklist) { true }
def authenticate
Setting.set('ai_provider', ai_provider)
Setting.set('ai_assistance_ticket_summary', ai_assistance_ticket_summary)
Setting.set('checklist', checklist)
article
@@ -23,11 +25,11 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
allow(AI::Service::TicketSummarize)
.to receive(:cache_key).and_return(initial_cache_key, :noop, updated_cache_key)
Rails.cache.write(initial_cache_key, { 'summary' => initial_summary })
Rails.cache.write(updated_cache_key, { 'summary' => updated_summary })
Rails.cache.write(initial_cache_key, { 'summary' => initial_summary, 'suggestions' => (initial_suggestions if defined?(initial_suggestions)) }.compact)
Rails.cache.write(updated_cache_key, { 'summary' => updated_summary, 'suggestions' => (updated_suggestions if defined?(updated_suggestions)) }.compact)
allow_any_instance_of(Service::Ticket::AIAssistance::Summarize)
.to receive(:execute).and_return({ 'summary' => updated_summary })
.to receive(:execute).and_return({ 'summary' => updated_summary, 'suggestions' => (updated_suggestions if defined?(updated_suggestions)) }.compact)
end
visit "ticket/zoom/#{ticket.id}"
@@ -35,16 +37,20 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
describe 'Sidebar' do
context 'when ai_provider is set' do
let(:initial_summary) { Faker::Lorem.sentence }
let(:updated_summary) { Faker::Lorem.sentence }
let(:initial_summary) { Faker::Lorem.unique.sentence }
let(:updated_summary) { Faker::Lorem.unique.sentence }
let(:initial_cache_key) { "ticket_summary_#{ticket.id}" }
let(:updated_cache_key) { "ticket_summary_#{ticket.id}_2" }
it 'displays and updates summary in sidebar', performs_jobs: true do
before do
click '.tabsSidebar-tab[data-tab=summary]'
end
expect(page).to have_text 'CONVERSATION SUMMARY'
expect(page).to have_text initial_summary
it 'displays and updates summary in sidebar', performs_jobs: true do
within '.sidebar[data-tab="summary"]' do
expect(page).to have_text 'Conversation Summary'
expect(page).to have_text initial_summary
end
create(:ticket_article, ticket:)
@@ -52,7 +58,52 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
perform_enqueued_jobs(only: TicketAIAssistanceSummarizeJob)
expect(page).to have_text updated_summary
within '.sidebar[data-tab="summary"]' do
expect(page).to have_text updated_summary
end
end
context 'when suggestions are available' do
let(:initial_suggestions) do
[
Faker::Lorem.unique.sentence,
Faker::Lorem.unique.sentence,
Faker::Lorem.unique.sentence,
]
end
it 'shows add buttons' do
within '.sidebar[data-tab="summary"]' do
expect(page).to have_text 'Suggested Next Steps'
expect(find_all('button[aria-label="Add as checklist item"]').length).to eq(3)
expect(page).to have_button 'Add all to checklist'
find_all('button[aria-label="Add as checklist item"]').first.click
end
expect(page).to have_text 'Checklist item successfully added.'
within '.sidebar[data-tab="summary"]' do
click_on 'Add all to checklist'
end
within '.sidebar[data-tab="checklist"]' do
expect(page).to have_text 'Checklist'
expect(find_all('table tbody tr').length).to eq(4)
end
end
context 'with checklist feature disabled' do
let(:checklist) { false }
it 'does not show add buttons' do
within '.sidebar[data-tab="summary"]' do
expect(page).to have_text 'Suggested Next Steps'
expect(page).to have_no_button 'Add'
expect(page).to have_no_button 'Add all to checklist'
end
end
end
end
end
@@ -73,7 +124,9 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
click_on('See Summary')
expect(page).to have_css('.js-headline', text: 'Summary')
within '.sidebar[data-tab="summary"]' do
expect(page).to have_text('Summary')
end
end
it 'allows to hide Summary banner' do