Feature: Desktop view - Support warning notes for creating articles

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Joe Schroecker <js@zammad.com>
This commit is contained in:
Benjamin Scharf 2026-04-21 22:41:19 +02:00
parent 439105bb1d
commit 68712c1006
11 changed files with 549 additions and 14 deletions

View file

@ -0,0 +1,64 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
describe('ticket create hint note', () => {
beforeEach(() => {
mockPermissions(['ticket.agent'])
})
it('shows the article-type note when ui_ticket_create_notes is configured for the selected type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'phone-in': 'Please fill in the call details carefully.',
},
})
const view = await visitView('/ticket/create')
await view.findByRole('tab', { selected: true, name: 'Received call' })
expect(await view.findByText('Please fill in the call details carefully.')).toBeVisible()
})
it('does not show a note when ui_ticket_create_notes has no entry for the selected type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'email-out': 'Email-only note.',
},
})
const view = await visitView('/ticket/create')
await view.findByRole('tab', { selected: true, name: 'Received call' })
expect(view.queryByText('Email-only note.')).not.toBeInTheDocument()
})
it('updates the article-type note when switching sender type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'phone-out': 'Note for outbound call.',
},
})
const view = await visitView('/ticket/create')
await view.findByRole('tab', { selected: true, name: 'Received call' })
// Default type (phone-in) has no note — nothing visible yet.
expect(view.queryByText('Note for outbound call.')).not.toBeInTheDocument()
await view.events.click(await view.findByText('Outbound call'))
expect(await view.findByText('Note for outbound call.')).toBeVisible()
})
})

View file

@ -0,0 +1,111 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getNode } from '@formkit/core'
import { within } from '@testing-library/vue'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import {
mockFormUpdaterQuery,
waitForFormUpdaterQueryCalls,
} from '#shared/components/Form/graphql/queries/formUpdater.mocks.ts'
import { mockTicketQuery } from '#shared/entities/ticket/graphql/queries/ticket.mocks.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
describe('article reply hint note', () => {
const makeFormUpdaterOptions = () => ({
formUpdater: {
fields: {
group_id: {
options: [
{ value: 1, label: 'Users' },
{ value: 2, label: 'test group' },
],
},
owner_id: { options: [{ value: 3, label: 'Test Admin Agent' }] },
state_id: {
options: [
{ value: 4, label: 'closed' },
{ value: 2, label: 'open' },
{ value: 6, label: 'pending close' },
{ value: 3, label: 'pending reminder' },
],
},
pending_time: { show: false },
priority_id: {
options: [
{ value: 1, label: '1 low' },
{ value: 2, label: '2 normal' },
{ value: 3, label: '3 high' },
],
},
},
flags: { newArticlePresent: false },
},
})
const openInternalNoteReply = async () => {
mockTicketQuery({
ticket: createDummyTicket({
defaultPolicy: { update: true, agentReadAccess: true },
}),
})
mockFormUpdaterQuery(makeFormUpdaterOptions())
const view = await visitView('/tickets/1')
await waitForFormUpdaterQueryCalls()
await view.events.click(await view.findByRole('button', { name: 'Add internal note' }))
const complementary = await view.findByRole('complementary', { name: 'Reply' })
await getNode('form-ticket-edit-1')?.settled
return { view, complementary }
}
it('shows the hint note when ui_ticket_add_article_hint is configured for the current article type and visibility', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-internal': 'Please keep internal notes concise.',
},
})
const { complementary } = await openInternalNoteReply()
// Reply form defaults to Internal — hint should be visible.
expect(
await within(complementary).findByText('Please keep internal notes concise.'),
).toBeVisible()
})
it('does not show the hint note when ui_ticket_add_article_hint has no entry for the current type and visibility', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-public': 'Public note hint.',
},
})
const { complementary } = await openInternalNoteReply()
expect(within(complementary).queryByText('Public note hint.')).not.toBeInTheDocument()
})
it('updates the hint note when visibility is changed', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-public': 'Public note hint.',
},
})
const { view, complementary } = await openInternalNoteReply()
expect(within(complementary).queryByText('Public note hint.')).not.toBeInTheDocument()
await view.events.click(within(complementary).getByLabelText('Visibility'))
await view.events.click(await view.findByRole('option', { name: 'Public' }))
await getNode('form-ticket-edit-1')?.settled
expect(await within(complementary).findByText('Public note hint.')).toBeVisible()
})
})

View file

@ -145,7 +145,18 @@ const formSchema = defineFormSchema([
props: {
variant: 'warning',
},
children: '$t($getAdditionalCreateNote($values.articleSenderType))',
children: [
{
isLayout: true,
element: 'div',
attrs: {
// We convert light weight markup
// The input is not sanitized and relies on the administrator to provide safe links
innerHTML: '$markup($t($getAdditionalCreateNote($values.articleSenderType)))',
},
children: '',
},
],
},
{
if: '$values.ticket_duplicate_detection.count > 0',
@ -277,7 +288,7 @@ const schemaData = reactive({
getTabLabel: (value: string) => `tab-label-${value}`,
getTabPanelId: (value: string) => `tab-panel-${value}`,
existingAdditionalCreateNotes: () => {
return Object.keys(additionalCreateNotes).length > 0
return Object.keys(additionalCreateNotes.value).length > 0
},
getAdditionalCreateNote: (value: string) => {
return additionalCreateNotes.value[value]

View file

@ -206,6 +206,7 @@ const {
ticketSchema,
articleSchema,
currentArticleType,
currentSchemaArticleType,
ticketArticleTypes,
ticketArticleDefaultValues,
securityIntegration,
@ -214,6 +215,7 @@ const {
isTicketEditable,
articleTypeHandler,
articleTypeSelectHandler,
additionalAddArticleNotes,
} = useTicketEditForm(ticket, form)
const { signatureHandling } = useTicketSignature('email')
@ -298,7 +300,17 @@ const ticketEditSchemaData = reactive({
formArticleReplyLocation,
securityIntegration,
newTicketArticlePresent,
currentArticleType,
currentArticleType: currentSchemaArticleType,
existingAdditionalAddArticleNotes: () => {
return Object.keys(additionalAddArticleNotes.value).length > 0
},
getAdditionalAddArticleNote: (articleType?: AppSpecificTicketArticleType) => {
if (!articleType) return undefined
const accessor = `${articleType.value}-${articleType.internal ? 'internal' : 'public'}`
return additionalAddArticleNotes.value[accessor]
},
})
const ticketEditSchema = [

View file

@ -0,0 +1,137 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getNode } from '@formkit/core'
import { flushPromises } from '@vue/test-utils'
import type { ExtendedRenderResult } from '#tests/support/components/index.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import '#tests/graphql/builders/mocks.ts'
import {
mockFormUpdaterQuery,
waitForFormUpdaterQueryCalls,
} from '#shared/components/Form/graphql/queries/formUpdater.mocks.ts'
describe('ticket create hint note', () => {
beforeEach(() => {
mockPermissions(['ticket.agent'])
})
const nextStep = async (view: ExtendedRenderResult) => {
await view.events.click(view.getByRole('button', { name: 'Continue' }))
}
const visitTicketCreate = async (path = '/tickets/create') => {
mockFormUpdaterQuery({
formUpdater: {
fields: {
group_id: {
show: true,
options: [
{
label: 'Users',
value: 1,
},
],
clearable: true,
},
owner_id: {
show: true,
options: [{ value: 100, label: 'Max Mustermann' }],
},
priority_id: {
show: true,
options: [
{ value: 1, label: '1 low' },
{ value: 2, label: '2 normal' },
{ value: 3, label: '3 high' },
],
clearable: true,
},
pending_time: {
show: false,
required: false,
hidden: false,
disabled: false,
},
state_id: {
show: true,
options: [
{ value: 4, label: 'closed' },
{ value: 2, label: 'open' },
{ value: 7, label: 'pending close' },
{ value: 3, label: 'pending reminder' },
],
clearable: true,
},
},
},
})
const view = await visitView(path, { mockApollo: false })
await flushPromises()
await getNode('ticket-create')?.settled
return view
}
it('shows the article-type note when ui_ticket_create_notes is configured for the selected type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'phone-in': 'Please fill in the call details carefully.',
},
})
const view = await visitTicketCreate()
await flushPromises()
await getNode('ticket-create')?.settled
await view.events.type(await view.findByLabelText('Title'), 'Foobar')
await waitForFormUpdaterQueryCalls()
await nextStep(view)
expect(await view.findByText('Please fill in the call details carefully.')).toBeVisible()
})
it('does not show a note when ui_ticket_create_notes has no entry for the selected type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'email-out': 'Email-only note.',
},
})
const view = await visitTicketCreate()
await view.events.click(await view.findByText('Received call'))
expect(view.queryByText('Email-only note.')).not.toBeInTheDocument()
})
it('updates the article-type note when switching sender type', async () => {
mockApplicationConfig({
ui_task_mananger_max_task_count: 30,
ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
ui_ticket_create_notes: {
'phone-out': 'Note for outbound call.',
},
})
const view = await visitTicketCreate()
await view.findByText('Received call')
// Default type (phone-in) has no note — nothing visible yet.
expect(view.queryByText('Note for outbound call.')).not.toBeInTheDocument()
await view.events.click(await view.findByText('Outbound call'))
expect(await view.findByText('Note for outbound call.')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,126 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getNode } from '@formkit/core'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import {
mockFormUpdaterQuery,
waitForFormUpdaterQueryCalls,
} from '#shared/components/Form/graphql/queries/formUpdater.mocks.ts'
import { mockTicketQuery } from '#shared/entities/ticket/graphql/queries/ticket.mocks.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
import { clearTicketArticlesLoadedState } from '../composable/useTicketArticlesVariables.ts'
import '#tests/graphql/builders/mocks.ts'
describe('article reply hint note', () => {
beforeEach(() => {
mockPermissions(['ticket.agent'])
clearTicketArticlesLoadedState()
})
const makeFormUpdaterOptions = () => ({
formUpdater: {
fields: {
group_id: {
options: [
{ value: 1, label: 'Users' },
{ value: 2, label: 'test group' },
],
},
owner_id: { options: [{ value: 3, label: 'Test Admin Agent' }] },
state_id: {
options: [
{ value: 4, label: 'closed' },
{ value: 2, label: 'open' },
{ value: 6, label: 'pending close' },
{ value: 3, label: 'pending reminder' },
],
},
pending_time: { show: false },
priority_id: {
options: [
{ value: 1, label: '1 low' },
{ value: 2, label: '2 normal' },
{ value: 3, label: '3 high' },
],
},
},
flags: { newArticlePresent: false },
},
})
const openInternalNoteReply = async () => {
mockTicketQuery({
ticket: createDummyTicket({
defaultPolicy: { update: true, agentReadAccess: true },
}),
})
mockFormUpdaterQuery(makeFormUpdaterOptions())
const view = await visitView('/tickets/1', { mockApollo: false })
await waitForFormUpdaterQueryCalls()
await getNode('form-ticket-edit-1')?.settled
await view.events.click(view.getByRole('button', { name: 'Add reply' }))
return { view }
}
it('shows the hint note when ui_ticket_add_article_hint is configured for the current article type and visibility', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-internal': 'Please keep internal notes concise.',
'note-public': 'Attention public note.',
},
})
const { view } = await openInternalNoteReply()
// Reply form defaults to Internal — hint should be visible.
await view.events.click(await view.findByText('Note'))
await view.findByRole('option', { selected: true, name: 'Note' })
expect(await view.findByText('Please keep internal notes concise.')).toBeVisible()
})
it('does not show the hint note when ui_ticket_add_article_hint has no entry for the current type and visibility', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-public': 'Public note hint.',
},
})
const { view } = await openInternalNoteReply()
// Reply form defaults to Internal — hint should be visible.
await view.events.click(await view.findByText('Note'))
await view.findByRole('option', { selected: true, name: 'Note' })
await view.events.click(await view.findByText('Internal'))
await view.events.click(await view.findByRole('option', { selected: true, name: 'Internal' }))
expect(view.queryByText('Public note hint.')).not.toBeInTheDocument()
})
it('updates the hint note when visibility is changed', async () => {
mockApplicationConfig({
ui_ticket_zoom_article_note_new_internal: true,
ui_ticket_add_article_hint: {
'note-public': 'Public note hint.',
},
})
const { view } = await openInternalNoteReply()
expect(view.queryByText('Public note hint.')).not.toBeInTheDocument()
await view.events.click(view.getByLabelText('Visibility'))
await view.events.click(await view.findByRole('option', { selected: false, name: 'Public' }))
await getNode('form-ticket-edit-1')?.settled
expect(await view.findByText('Public note hint.')).toBeVisible()
})
})

View file

@ -153,11 +153,22 @@ const ticketArticleTypeSection = getFormSchemaGroupSection(
{
if: '$existingAdditionalCreateNotes() && $getAdditionalCreateNote($values.articleSenderType) !== undefined',
isLayout: true,
element: 'p',
attrs: {
class: 'my-10 text-base text-center text-yellow',
component: 'CommonAlert',
props: {
variant: 'warning',
},
children: '$getAdditionalCreateNote($values.articleSenderType)',
children: [
{
isLayout: true,
element: 'div',
attrs: {
// We convert light weight markup
// The input is not sanitized and relies on the administrator to provide safe content
innerHTML: '$markup($t($getAdditionalCreateNote($values.articleSenderType)))',
},
children: '',
},
],
},
],
true,
@ -352,10 +363,10 @@ const schemaData = reactive({
allSteps,
securityIntegration,
existingAdditionalCreateNotes: () => {
return Object.keys(additionalCreateNotes).length > 0
return Object.keys(additionalCreateNotes.value).length > 0
},
getAdditionalCreateNote: (value: string) => {
return i18n.t(additionalCreateNotes.value[value])
return additionalCreateNotes.value[value]
},
})

View file

@ -26,6 +26,7 @@ import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicket
import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
import { TicketUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.api.ts'
import type { TicketUpdateFormData } from '#shared/entities/ticket/types.ts'
import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
import UserError from '#shared/errors/UserError.ts'
import type {
@ -93,10 +94,12 @@ const {
currentArticleType,
ticketSchema,
articleSchema,
currentSchemaArticleType,
securityIntegration,
isTicketEditable,
articleTypeHandler,
articleTypeSelectHandler,
additionalAddArticleNotes,
} = useTicketEditForm(ticket, form)
const needSpaceForSaveBanner = computed(() => isTicketEditable.value && isDirty.value)
@ -283,7 +286,17 @@ const ticketEditSchemaData = reactive({
securityIntegration,
newTicketArticleRequested,
newTicketArticlePresent,
currentArticleType,
currentArticleType: currentSchemaArticleType,
existingAdditionalAddArticleNotes: () => {
return Object.keys(additionalAddArticleNotes.value).length > 0
},
getAdditionalAddArticleNote: (articleType?: AppSpecificTicketArticleType) => {
if (!articleType) return undefined
const accessor = `${articleType.value}-${articleType.internal ? 'internal' : 'public'}`
return additionalAddArticleNotes.value[accessor]
},
})
const { isOpened: commonSelectOpened } = useCommonSelect()

View file

@ -41,6 +41,22 @@ export const useTicketEditForm = (
const currentArticleType = shallowRef<AppSpecificTicketArticleType>()
const hasInternalArticle = computed(
() =>
(form.value?.values?.article as { internal?: boolean })?.internal ??
currentArticleType.value?.internal ??
false,
)
const currentSchemaArticleType = computed(() => {
if (!currentArticleType.value) return undefined
return {
...currentArticleType.value,
internal: hasInternalArticle.value,
}
})
const recipientContact = computed(() => currentArticleType.value?.options?.recipientContact)
const editorType = computed(() => currentArticleType.value?.contentType)
@ -88,6 +104,12 @@ export const useTicketEditForm = (
const isMobileApp = appName === 'mobile'
const application = useApplicationStore()
const additionalAddArticleNotes = computed(
() => (application.config.ui_ticket_add_article_hint as Record<string, string>) || {},
)
const ticketSchema = {
type: 'group',
name: 'ticket', // will be flattened in the form submit result
@ -125,6 +147,27 @@ export const useTicketEditForm = (
name: 'article',
isGroupOrList: true,
children: [
{
if: '$existingAdditionalAddArticleNotes() && $getAdditionalAddArticleNote($currentArticleType) !== undefined',
isLayout: true,
component: 'CommonAlert',
props: {
variant: 'warning',
class: 'col-span-2',
},
children: [
{
isLayout: true,
element: 'div',
attrs: {
// We convert light weight markup
// The input is not sanitized and relies on the administrator to provide safe content
innerHTML: '$markup($t($getAdditionalAddArticleNote($currentArticleType)))',
},
children: '',
},
],
},
{
type: 'hidden',
name: 'inReplyTo',
@ -332,8 +375,6 @@ export const useTicketEditForm = (
})
}
const application = useApplicationStore()
const securityIntegration = computed<boolean>(
() => (application.config.smime_integration || application.config.pgp_integration) ?? false,
)
@ -342,6 +383,7 @@ export const useTicketEditForm = (
ticketSchema,
articleSchema,
currentArticleType,
currentSchemaArticleType,
ticketArticleTypes,
ticketArticleDefaultValues,
securityIntegration,
@ -350,5 +392,6 @@ export const useTicketEditForm = (
isTicketEditable,
articleTypeHandler: articleTypeChangeHandler,
articleTypeSelectHandler,
additionalAddArticleNotes,
}
}

View file

@ -9,8 +9,9 @@ describe('markup()', () => {
expect(markup('_underline_')).toBe('<u>underline</u>')
expect(markup('//strikethrough//')).toBe('<del>strikethrough</del>')
expect(markup('§keyboard§')).toBe('<kbd>keyboard</kbd>')
expect(markup('Paragraph¶New line')).toBe('Paragraph<br>New line')
expect(markup('[link](https://zammad.org)')).toBe(
'<a href="https://zammad.org" target="_blank">link</a>',
'<a href="https://zammad.org" target="_blank" rel="noopener noreferrer">link</a>',
)
})
@ -26,6 +27,7 @@ describe('cleanupMarkup()', () => {
expect(cleanupMarkup('_underline_')).toBe('underline')
expect(cleanupMarkup('//strikethrough//')).toBe('strikethrough')
expect(cleanupMarkup('§keyboard§')).toBe('keyboard')
expect(cleanupMarkup('Paragraph¶New line')).toBe('ParagraphNew line')
expect(cleanupMarkup('[link](https://zammad.org)')).toBe('link')
})
})

View file

@ -10,7 +10,11 @@ export const markup = (source: string): string =>
.replace(/_(.+?)_/gm, '<u>$1</u>')
.replace(/\/\/(.+?)\/\//gm, '<del>$1</del>')
.replace(/§(.+?)§/gm, '<kbd>$1</kbd>')
.replace(/\[(.+?)\]\((.+?)\)/gm, '<a href="$2" target="_blank">$1</a>')
.replace(/¶/gm, '<br>')
.replace(
/\[(.+?)\]\((.+?)\)/gm,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
)
export const cleanupMarkup = (source: string): string =>
source
@ -19,6 +23,7 @@ export const cleanupMarkup = (source: string): string =>
.replace(/_(.+?)_/gm, '$1')
.replace(/\/\/(.+?)\/\//gm, '$1')
.replace(/§(.+?)§/gm, '$1')
.replace(/¶/gm, '')
.replace(/\[(.+?)\]\((.+?)\)/gm, '$1')
export const normalizeImageSizingInHtml = (html: string) => {