Maintenance: Email signature can be silently skipped when opening the article reply form (mobile & desktop view).

Approved-by: Benjamin Scharf <bs@zammad.com>
This commit is contained in:
Dominik Klein
2026-08-17 19:43:25 +02:00
parent d62e6848c7
commit 6a7e5cd862
17 changed files with 521 additions and 503 deletions

View File

@@ -14,7 +14,6 @@ import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import { useTicketCreate } from '#shared/entities/ticket/composables/useTicketCreate.ts'
import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
import { useTicketFormOrganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
import { useTicketSignature } from '#shared/entities/ticket/composables/useTicketSignature.ts'
import type { TicketFormData } from '#shared/entities/ticket/types.ts'
import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
import {
@@ -215,6 +214,7 @@ const formSchema = defineFormSchema([
organizationNodeName: 'organization_id',
},
},
signatureEnabled: computed(() => values.value.articleSenderType === 'email-out'),
},
},
{
@@ -325,8 +325,6 @@ const changedFields = reactive({
},
})
const { signatureHandling } = useTicketSignature()
const tabContext = computed<TaskbarTabContext>((currentContext) => {
if (!isInitialSettled.value) return {}
@@ -412,7 +410,7 @@ const submitCreateTicket = async (event: FormSubmitData<TicketFormData>) => {
}"
:schema-data="schemaData"
:form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
:handlers="[useTicketFormOrganizationHandler(), signatureHandling('body')]"
:handlers="[useTicketFormOrganizationHandler()]"
:change-fields="changedFields"
:form-updater-additional-params="formAdditionalRouteQueryParams"
use-object-attributes

View File

@@ -37,7 +37,6 @@ import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit
import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
import { useTicketLiveUserList } from '#shared/entities/ticket/composables/useTicketLiveUserList.ts'
import { useTicketNumberAndTitle } from '#shared/entities/ticket/composables/useTicketNumberAndTitle.ts'
import { useTicketSignature } from '#shared/entities/ticket/composables/useTicketSignature.ts'
import type {
TicketArticleTimeAccountingFormData,
TicketUpdateFormData,
@@ -255,8 +254,6 @@ const {
additionalAddArticleNotes,
} = useTicketEditForm(ticket, form)
const { signatureHandling } = useTicketSignature('email')
const {
isArticleFormGroupValid,
newTicketArticlePresent,
@@ -774,7 +771,7 @@ const handleShowArticleForm = (
:disabled="!isTicketEditable"
:flatten-form-groups="['ticket']"
:hidden-form-groups="hiddenFormGroups"
:handlers="[articleTypeHandler(), signatureHandling('body')]"
:handlers="[articleTypeHandler()]"
:form-kit-plugins="[articleTypeSelectHandler]"
:schema-data="ticketEditSchemaData"
:initial-values="initialTicketValue"

View File

@@ -16,7 +16,6 @@ import { useTicketCreate } from '#shared/entities/ticket/composables/useTicketCr
import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
import { useTicketCreateView } from '#shared/entities/ticket/composables/useTicketCreateView.ts'
import { useTicketFormOrganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
import { useTicketSignature } from '#shared/entities/ticket/composables/useTicketSignature.ts'
import type { TicketFormData } from '#shared/entities/ticket/types.ts'
import { useUserQuery } from '#shared/entities/user/graphql/queries/user.api.ts'
import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
@@ -41,7 +40,7 @@ const router = useRouter()
// TODO: Add meta header with selected ticket create article type.
const { canSubmit, form, node, isDirty, formSubmit } = useForm()
const { canSubmit, form, node, isDirty, formSubmit, values } = useForm()
const {
multiStepPlugin,
@@ -327,6 +326,7 @@ const ticketArticleMessageSection = getFormSchemaGroupSection(
organizationNodeName: 'organization_id',
},
},
signatureEnabled: computed(() => values.value.articleSenderType === 'email-out'),
},
triggerFormUpdater: false,
},
@@ -443,8 +443,6 @@ onBeforeRouteLeave(async () => {
return confirmed
})
const { signatureHandling } = useTicketSignature()
const ticketDuplicateDetectionDialog = useDialog({
name: 'duplicate-ticket-detection',
component: () => import('#mobile/components/Ticket/TicketDuplicateDetectionDialog.vue'),
@@ -520,7 +518,6 @@ export default {
:schema="formSchema"
:handlers="[
useTicketFormOrganizationHandler(),
signatureHandling('body'),
useTicketDuplicateDetectionHandler(showTicketDuplicateDetectionDialog),
]"
:flatten-form-groups="Object.keys(allSteps)"

View File

@@ -0,0 +1,159 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getNode } from '@formkit/core'
import { mountEditor } from './utils.ts'
const html = String.raw
const SIGNATURE_BODY = '<strong>Test Signature</strong>'
const signature = (internalId: number, renderedBody = SIGNATURE_BODY) => ({
renderedBody,
internalId,
})
const QUOTE_BODY = html`<blockquote type="cite" data-marker="signature-before">
<p>Quoted message</p>
</blockquote>`
describe('editor signature reconciliation', { retries: 2 }, () => {
it('applies an already present signature on mount', () => {
// The editor may mount after the article form is already set up (async import,
// late dialog rendering) - the signature props are then present at mount time.
mountEditor({ signatureEnabled: true, signature: signature(1) })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
})
it('applies a late arriving signature', () => {
mountEditor({ signatureEnabled: true })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).not.to.include('data-signature')
})
cy.then(() => {
getNode('editor')!.props.signature = signature(1)
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
})
it('adds and removes the signature when the article type flag flips', () => {
mountEditor({ signatureEnabled: false, signature: signature(1) })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).not.to.include('data-signature')
})
cy.then(() => {
getNode('editor')!.props.signatureEnabled = true
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
cy.then(() => {
getNode('editor')!.props.signatureEnabled = false
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).not.to.include('data-signature')
})
})
it('applies the signature again after an external content write replaced the document', () => {
mountEditor({ signatureEnabled: true, signature: signature(1) })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
// Simulates the reply/forward quote write, which replaces the whole document
// and thereby destroys an already applied signature.
cy.then(() => {
getNode('editor')!.input(QUOTE_BODY, false)
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('Quoted message')
expect($el.html()).to.include('data-signature-id="1"')
})
// The signature must sit before the full quote.
cy.findByRole('textbox').should(($el) => {
const content = $el.html()
expect(content.indexOf('data-signature-id="1"')).to.be.lessThan(
content.indexOf('Quoted message'),
)
})
})
it('applies the signature again after an external write cleared the document', () => {
// With the full quote disabled the reply form has no quote to write, so it writes an
// empty body after the form settled - which used to wipe the applied signature (#733).
mountEditor({ signatureEnabled: true, signature: signature(1) })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
cy.then(() => {
getNode('editor')!.input('', false)
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="1"')
})
})
it('does not re-apply the signature on a cleared document when the handling is disabled', () => {
// Article discard clears the article type before it resets the field values, so the
// reset must not bring the signature back (which would leave the form dirty).
mountEditor({ signatureEnabled: true, signature: signature(1) })
cy.then(() => {
getNode('editor')!.props.signatureEnabled = false
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).not.to.include('data-signature')
})
cy.then(() => {
getNode('editor')!.input('', false)
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).not.to.include('data-signature')
})
})
it('never touches present signature nodes when the handling is not engaged', () => {
// E.g. article edit or note editors: content can contain a previously sent
// signature, but without a declared signatureEnabled prop nothing is wired up.
const contentWithSignature = html`<p>Some article content</p>
<div data-signature="true" data-signature-id="9"><p>Sent signature</p></div>`
mountEditor({ value: contentWithSignature })
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('data-signature-id="9"')
})
cy.then(() => {
getNode('editor')!.input(`<p>Edited</p>${contentWithSignature}`, false)
})
cy.findByRole('textbox').should(($el) => {
expect($el.html()).to.include('<p dir="auto">Edited</p>')
expect($el.html()).to.include('data-signature-id="9"')
})
})
})

View File

@@ -138,6 +138,13 @@ const {
inputInlineDesktopTextStyles,
} = useInlineMode(toRef(props, 'context'), wrapperElement)
// Tracks edits since the last external content write, to distinguish an opening
// document setup (reset the cursor on signature apply) from an in-session change
// like a group switch while typing (keep the cursor position). The signature
// apply itself also emits an editor update - it must not count as a user edit.
let userHasEditedContent = false
let applyingSignature = false
const editor = useEditor({
extensions: editorExtensions.value,
textDirection: 'auto',
@@ -197,6 +204,8 @@ const editor = useEditor({
? htmlCleanup(currentValue.value)
: currentValue.value,
onUpdate({ editor }) {
if (!applyingSignature) userHasEditedContent = true
const value = getEditorContent(editor as Editor, contentType.value)
props.context.node.input(value)
@@ -244,6 +253,37 @@ watch(
},
)
const { addSignature, removeSignature, reconcileSignature } = useSignatureHandling(editor)
// Signature handling is engaged statically per editor usage: the field schema either
// declares the signatureEnabled prop (ticket article body) or the usage has no
// signature semantics at all (profile notes, KB, admin) - then nothing is wired up.
const signatureHandlingEngaged = props.context.signatureEnabled !== undefined
const signatureTarget = computed(
() => (props.context.signatureEnabled && props.context.signature) || null,
)
const syncSignature = (resetCursor = false) => {
if (!signatureHandlingEngaged) return
// The reconcile dispatches its transactions synchronously.
applyingSignature = true
try {
reconcileSignature(signatureTarget.value, { resetCursor })
} finally {
applyingSignature = false
}
}
if (signatureHandlingEngaged) {
// React to a late arriving signature (form updater response) and to article type or
// group changes. Content writes and mount are handled at their own call sites.
// On an untouched document the apply belongs to the form opening (e.g. the article
// type flips to email after the reply quote was written) - reset the cursor then.
watch(signatureTarget, () => syncSignature(!userHasEditedContent), { flush: 'post' })
}
const setEditorContent = (
content: string | undefined,
contentType: EditorContentType,
@@ -267,6 +307,12 @@ const updateValueKey = props.context.node.on('input', ({ payload: newContent })
if (newContent === currentContent) return
setEditorContent(newContent, contentType.value, true)
// An external content write replaces the whole document and thereby destroys any
// applied signature - reconcile it afterwards (e.g. reply/forward quote writes).
// It also resets the edit tracking: the document is a fresh setup again.
userHasEditedContent = false
syncSignature(true)
})
// Convert the current editor content, if the content type changed from outside (e.g. form schema update).
@@ -313,8 +359,6 @@ const characters = computed(() => {
})
})
const { addSignature, removeSignature } = useSignatureHandling(editor)
const editorCustomContext = {
_loaded: true,
getEditorValue: (type: EditorContentType) => {
@@ -335,6 +379,10 @@ onMounted(() => {
onLoad.forEach((fn) => fn(editorCustomContext))
onLoad.length = 0
// The editor may mount after the article form is already set up (async import,
// late dialog rendering) - reconcile the signature it may have missed (#804).
syncSignature(true)
if (VITE_TEST_MODE) {
testFlags.set(`${props.context.formId}.${props.context.node.name}.editor.ready`)

View File

@@ -0,0 +1,179 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import Blockquote from '@tiptap/extension-blockquote'
import Paragraph from '@tiptap/extension-paragraph'
import StarterKit from '@tiptap/starter-kit'
import { Editor } from '@tiptap/vue-3'
import { shallowRef } from 'vue'
import Signature from '#shared/components/Form/fields/FieldEditor/extensions/Signature.ts'
import { useSignatureHandling } from '../useSignatureHandling.ts'
// Mirrors the data-marker attribute setup of the html extension set.
const extensions = [
StarterKit.configure({ blockquote: false, paragraph: false, link: false }),
Blockquote.extend({
addAttributes() {
return {
...this.parent?.(),
type: { default: null },
'data-marker': { default: null },
}
},
}),
Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
'data-marker': { default: null },
}
},
}),
Signature,
]
const editors: Editor[] = []
const createEditor = (content = '', editorExtensions = extensions) => {
const editor = new Editor({ extensions: editorExtensions, content })
editors.push(editor)
return editor
}
const signatureTarget = (internalId: number, text = `Signature ${internalId}`) => ({
internalId,
renderedBody: `<p>${text}</p>`,
})
// Destroy the editor views, otherwise the ProseMirror DOM observer can flush after
// the test environment was torn down already ("document is not defined").
afterEach(() => {
editors.forEach((editor) => editor.destroy())
editors.length = 0
})
describe('useSignatureHandling > reconcileSignature', () => {
it('applies the signature when none is present', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
expect(editor.value.getHTML()).toContain('data-signature-id="1"')
expect(editor.value.getHTML()).toContain('Signature 1')
})
it('keeps a present signature with the same id untouched', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1, 'Original content'))
reconcileSignature(signatureTarget(1, 'Refreshed content'))
expect(editor.value.getHTML()).toContain('Original content')
expect(editor.value.getHTML()).not.toContain('Refreshed content')
})
it('replaces a present signature when the id differs', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
reconcileSignature(signatureTarget(2))
expect(editor.value.getHTML()).toContain('data-signature-id="2"')
expect(editor.value.getHTML()).not.toContain('data-signature-id="1"')
})
it('removes the signature when no target is given', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
reconcileSignature(null)
expect(editor.value.getHTML()).not.toContain('data-signature')
expect(editor.value.getHTML()).toContain('Hello')
})
it('keeps a quoted signature inside a blockquote untouched', () => {
const editor = shallowRef(
createEditor(
'<blockquote type="cite"><div data-signature="true" data-signature-id="1"><p>Quoted signature</p></div></blockquote>',
),
)
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(null)
expect(editor.value.getHTML()).toContain('Quoted signature')
})
it('applies the signature again after an external content write removed it', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
editor.value.commands.setContent('<p>Replaced content</p>')
expect(editor.value.getHTML()).not.toContain('data-signature')
reconcileSignature(signatureTarget(1))
expect(editor.value.getHTML()).toContain('data-signature-id="1"')
expect(editor.value.getHTML()).toContain('Replaced content')
})
it('inserts the signature before the full quote marker', () => {
const editor = shallowRef(
createEditor(
'<blockquote type="cite" data-marker="signature-before"><p>Quoted message</p></blockquote>',
),
)
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
const html = editor.value.getHTML()
expect(html.indexOf('data-signature-id="1"')).toBeLessThan(html.indexOf('Quoted message'))
})
it('sets the cursor to the document start when the cursor reset is requested', () => {
const editor = shallowRef(
createEditor(
'<blockquote type="cite" data-marker="signature-before"><p>Quoted message</p></blockquote>',
),
)
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1), { resetCursor: true })
// A later focus must land at the document start, not inside the quote.
expect(editor.value.state.selection.from).toBe(1)
})
it('does nothing when the signature node is not part of the schema', () => {
const editor = shallowRef(createEditor('<p>Hello</p>', [StarterKit]))
const { reconcileSignature } = useSignatureHandling(editor)
expect(() => {
reconcileSignature(signatureTarget(1))
reconcileSignature(null)
}).not.toThrow()
expect(editor.value.getHTML()).not.toContain('data-signature')
})
it('does nothing when the editor is not editable', () => {
const editor = shallowRef(createEditor('<p>Hello</p>'))
editor.value.setEditable(false)
const { reconcileSignature } = useSignatureHandling(editor)
reconcileSignature(signatureTarget(1))
expect(editor.value.getHTML()).not.toContain('data-signature')
})
})

View File

@@ -9,20 +9,27 @@ import testFlags from '#shared/utils/testFlags.ts'
import type { Editor } from '@tiptap/vue-3'
import type { ShallowRef } from 'vue'
const findTopLevelSignature = (editor: Editor): { pos: number; node: ProseMirrorNode } | null => {
let existingSignature: { pos: number; node: ProseMirrorNode } | null = null
editor.state.doc.descendants((node, pos, parent) => {
if (node.type.name === 'signature' && parent?.type.name === 'doc') {
existingSignature = { pos, node }
return false
}
})
return existingSignature
}
const tryToUpsertSignature = (
editor: ShallowRef<Editor | undefined>,
signature: PossibleSignature,
options: { wasFocused: boolean; currentPosition: number },
) => {
const { wasFocused, currentPosition } = options
let existingSignature: { pos: number; node: ProseMirrorNode } | null = null
editor.value?.state.doc.descendants((node, pos, parent) => {
if (node.type.name === 'signature' && parent?.type.name === 'doc') {
existingSignature = { pos, node }
return false
}
})
const existingSignature = editor.value ? findTopLevelSignature(editor.value) : null
if (existingSignature && editor.value) {
const signatureElement = htmlCleanup(
@@ -37,7 +44,7 @@ const tryToUpsertSignature = (
if (!slice) return false
const { pos, node } = existingSignature as { pos: number; node: ProseMirrorNode }
const { pos, node } = existingSignature
editor.value.commands.insertContentAt(
{
@@ -137,8 +144,49 @@ export const useSignatureHandling = (editor: ShallowRef<Editor | undefined>) =>
})
}
// Reconcile the editor document against the given target signature, so the caller
// never needs to know whether or when the signature was already applied.
const reconcileSignature = (
target: Pick<PossibleSignature, 'internalId' | 'renderedBody'> | null,
options: { resetCursor?: boolean } = {},
) => {
if (!editor.value || editor.value.isDestroyed || !editor.value.isEditable) return
// Signature nodes are only part of the HTML extension set.
if (!editor.value.schema.nodes.signature) return
const existingSignature = findTopLevelSignature(editor.value)
if (!target) {
if (existingSignature) removeSignature()
return
}
// The same signature is already present - leave it untouched, so restored drafts
// and user edits inside the signature are preserved (#2319).
if (
existingSignature &&
String(existingSignature.node.attrs.signatureId) === String(target.internalId)
)
return
addSignature({
renderedBody: target.renderedBody,
internalId: target.internalId,
position: options.resetCursor ? 1 : undefined,
})
// addSignature positions the cursor only for an already focused editor. Set the
// selection also for an unfocused one, so a later focus (e.g. the queued reply
// form focus) lands at the document start instead of inside the applied content.
if (options.resetCursor && !editor.value.isFocused) {
editor.value.commands.setTextSelection(1)
}
}
return {
addSignature,
removeSignature,
reconcileSignature,
}
}

View File

@@ -33,6 +33,7 @@ const fieldDefinition = createInput(
'customerId',
'organizationId',
'signature',
'signatureEnabled',
'meta',
'contentType',
'inline',

View File

@@ -71,6 +71,7 @@ export interface FieldEditorContext {
focus(): void
getEditorValue(type: EditorContentType): string
signature?: Pick<PossibleSignature, 'internalId' | 'renderedBody'> | null
signatureEnabled?: boolean
}
export type EditorExtensionSet = 'basic'
@@ -83,6 +84,15 @@ export interface FieldEditorProps {
inline?: boolean
extensionSet?: EditorExtensionSet
signature?: Pick<PossibleSignature, 'internalId' | 'renderedBody'> | null
/**
* Whether the current article type carries a signature. The editor reconciles its
* content against `signature` whenever this or `signature` changes.
*
* Must be statically declared in the field schema: engagement is decided once at
* editor creation. When absent, no signature handling is wired up at all and the
* editor never touches signature nodes (e.g. article edit forms, notes, KB).
*/
signatureEnabled?: boolean
reset?: () => void
organizationId?: string
/**

View File

@@ -15,7 +15,6 @@ import type {
TicketFieldsType,
TicketArticleAction,
TicketArticleActionPlugin,
TicketArticleSelectionOptions,
TicketArticleType,
} from './types.ts'
@@ -31,19 +30,6 @@ const canReplyAll = (article: TicketArticle) => {
return uniq(foreignRecipients).length > 1
}
const addSignature = ({ body }: TicketArticleSelectionOptions, position?: number) => {
// Get signature from form props (set by form updater)
const { signature } = body
if (!signature) return body.removeSignature()
body.addSignature({
renderedBody: signature.renderedBody,
internalId: signature.internalId,
position,
})
}
const actionPlugin: TicketArticleActionPlugin = {
order: 200,
@@ -149,20 +135,8 @@ const actionPlugin: TicketArticleActionPlugin = {
icon: 'mail',
view: { agent: ['change'] },
fields,
onDeselected(_, { body }) {
body.removeSignature()
},
onOpened(_, { body }) {
// solve the issue in firefox that the signature is not inserted
// always reset position if reply is added as a new article
requestAnimationFrame(() => {
addSignature({ body }, 1)
})
},
onSelected(_, { body }) {
// try to dynamically set cursor position, depending on where it was before signature was added
addSignature({ body })
},
// The editor applies and removes the group signature itself, based on this flag.
signature: true,
internal: false,
performReply(ticket) {
return {

View File

@@ -176,7 +176,6 @@ export const replyToEmail = (
const body =
(selection || '') + (currentBody && selection ? `<p></p>${currentBody}` : currentBody)
// signature is handled in article type "onSelected" hook
options.openReplyForm({
...newArticle,
subtype: 'reply',

View File

@@ -2,7 +2,6 @@
import type {
EditorContentType,
FieldEditorContext,
FieldEditorProps,
} from '#shared/components/Form/fields/FieldEditor/types.ts'
import type { FileUploaded } from '#shared/components/Form/fields/FieldFile/types.ts'
@@ -23,10 +22,6 @@ import type { SelectionData } from '#shared/utils/selection.ts'
import type { ComputedRef } from 'vue'
export interface TicketArticleSelectionOptions {
body: FieldEditorContext
}
export interface TicketArticleFormValues {
articleType?: string
body?: string
@@ -129,21 +124,16 @@ export interface AppSpecificTicketArticleType {
contentType?: FieldEditorProps['contentType']
editorMeta?: FieldEditorProps['meta']
// when clicked on type, and type is not selected, or when dialog is opened with this type
onOpened?(
ticket: TicketById,
options: TicketArticleSelectionOptions,
form: FormRef | undefined,
): void
// Whether the article type carries the group email signature (applied by the editor itself).
signature?: boolean
onSelected?(
ticket: TicketById,
options: TicketArticleSelectionOptions,
form: FormRef | undefined,
): void
// when clicked on type, and type is not selected, or when dialog is opened with this type
onOpened?(ticket: TicketById, form: FormRef | undefined): void
onSelected?(ticket: TicketById, form: FormRef | undefined): void
// when clicked on other type, but this one is selected
onDeselected?(ticket: TicketById, options: TicketArticleSelectionOptions): void
onDeselected?(ticket: TicketById, form: FormRef | undefined): void
updateForm?(
formValues: FormSubmitData<TicketFormData | TicketUpdateFormData>,

View File

@@ -212,8 +212,8 @@ const actionPlugin: TicketArticleActionPlugin = {
onDeselected: () => {
deRegisterListeners()
},
onSelected: (ticket, context, form) => handleAllowedBody(form),
onOpened: (ticket, context, form) => handleAllowedBody(form),
onSelected: (ticket, form) => handleAllowedBody(form),
onOpened: (ticket, form) => handleAllowedBody(form),
}
return [type]
},

View File

@@ -1,326 +0,0 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { nextTick, reactive } from 'vue'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import type {
FormHandlerFunctionData,
FormHandlerFunctionReactivity,
} from '#shared/components/Form/types.ts'
import { useTicketSignature } from '../useTicketSignature.ts'
// The signature watcher uses `flush: 'post'`, so two ticks are needed:
// one for the reactive change, one for the post-flush callback.
const waitForPostFlushWatcher = async () => {
await nextTick()
await nextTick()
}
type MockEditorContext = {
signature: { internalId: number; renderedBody: string } | null
addSignature: ReturnType<typeof vi.fn>
removeSignature: ReturnType<typeof vi.fn>
}
const createMockEditorContext = (): MockEditorContext =>
reactive({
signature: null,
addSignature: vi.fn(),
removeSignature: vi.fn(),
})
const createMockFormNode = (editorContext: MockEditorContext) =>
({
find: vi.fn().mockReturnValue({ context: editorContext }),
}) as unknown as FormHandlerFunctionData['formNode']
const createMockReactivity = (): FormHandlerFunctionReactivity =>
({
changeFields: { value: {} },
schemaData: { fields: {}, flags: {} },
updateSchemaDataField: vi.fn(),
}) as unknown as FormHandlerFunctionReactivity
describe('useTicketSignature', () => {
describe('signatureHandling for ticket create (email-out)', () => {
it('sets up signature watcher on initial execution when group is pre-selected', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
// Simulate Initial execution with group_id and articleSenderType pre-set
handler.callback(FormHandlerExecution.Initial, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: undefined,
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
// Simulate form updater updating the signature
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalledWith({
renderedBody: '<p>Signature 1</p>',
internalId: 1,
})
})
it('does not set up watcher on initial execution when no group is selected', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.Initial, createMockReactivity(), {
formNode,
values: { group_id: undefined, articleSenderType: 'email-out' },
changedField: undefined,
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await nextTick()
expect(editorContext.addSignature).not.toHaveBeenCalled()
// removeSignature is called when no group is selected (cleanup)
expect(editorContext.removeSignature).toHaveBeenCalled()
})
it('does not set up watcher on initial execution when sender type does not match', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.Initial, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'phone-out' },
changedField: undefined,
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await nextTick()
expect(editorContext.addSignature).not.toHaveBeenCalled()
expect(editorContext.removeSignature).toHaveBeenCalled()
})
it('sets up signature watcher when group_id changes', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 1, oldValue: null },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalledWith({
renderedBody: '<p>Signature 1</p>',
internalId: 1,
})
})
it('updates signature when group changes to one with different signature', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
// First group selection
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 1, oldValue: null },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalledWith({
renderedBody: '<p>Signature 1</p>',
internalId: 1,
})
// Change to second group
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 2, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 2, oldValue: 1 },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 2, renderedBody: '<p>Signature 2</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenLastCalledWith({
renderedBody: '<p>Signature 2</p>',
internalId: 2,
})
})
it('removes signature when group changes to one without signature', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
// First group selection with signature
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 1, oldValue: null },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await waitForPostFlushWatcher()
// Change to group without signature
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 2, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 2, oldValue: 1 },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = null
await waitForPostFlushWatcher()
expect(editorContext.removeSignature).toHaveBeenCalled()
})
it('removes signature when sender type changes away from email-out', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
// Set up with email-out
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: { name: 'group_id', newValue: 1, oldValue: null },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature 1</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalled()
// Switch sender type away from email-out
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'phone-out' },
changedField: { name: 'articleSenderType', newValue: 'phone-out', oldValue: 'email-out' },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
expect(editorContext.removeSignature).toHaveBeenCalled()
})
it('ignores field changes that are not group_id or articleSenderType', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email-out')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: { group_id: 1, articleSenderType: 'email-out' },
changedField: { name: 'title', newValue: 'Test', oldValue: '' },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
expect(editorContext.addSignature).not.toHaveBeenCalled()
expect(editorContext.removeSignature).not.toHaveBeenCalled()
})
})
describe('signatureHandling for ticket detail view (email)', () => {
it('sets up signature watcher when article type changes to email', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: {
group_id: 1,
article: { articleType: 'email' },
},
changedField: { name: 'articleSenderType', newValue: 'email', oldValue: undefined },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 1, renderedBody: '<p>Signature</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalledWith({
renderedBody: '<p>Signature</p>',
internalId: 1,
})
})
it('sets up signature watcher when group_id changes in detail view', async () => {
const editorContext = createMockEditorContext()
const formNode = createMockFormNode(editorContext)
const { signatureHandling } = useTicketSignature('email')
const handler = signatureHandling('body')
handler.callback(FormHandlerExecution.FieldChange, createMockReactivity(), {
formNode,
values: {
group_id: 2,
article: { articleType: 'email' },
},
changedField: { name: 'group_id', newValue: 2, oldValue: 1 },
getNodeByName: vi.fn(),
findNodeByName: vi.fn(),
})
editorContext.signature = { internalId: 2, renderedBody: '<p>New Group Signature</p>' }
await waitForPostFlushWatcher()
expect(editorContext.addSignature).toHaveBeenCalledWith({
renderedBody: '<p>New Group Signature</p>',
internalId: 2,
})
})
})
})

View File

@@ -4,7 +4,6 @@ import { keyBy } from 'lodash-es'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { EXTENSION_NAME as TEXT_TOOL_PLUGIN_NAME } from '#shared/components/Form/fields/FieldEditor/extensions/AiAssistantTextTools.ts'
import type { FieldEditorContext } from '#shared/components/Form/fields/FieldEditor/types.ts'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
import type {
ChangedField,
@@ -312,6 +311,7 @@ export const useTicketEditForm = (
organizationId: computed(() => ticket.value?.organization?.internalId),
contentType: editorType,
meta: editorMeta,
signatureEnabled: computed(() => !!currentArticleType.value?.signature),
},
required: articleTypeFieldProps.body.required,
},
@@ -500,13 +500,8 @@ export const useTicketEditForm = (
)
return
const body = formNode.find('body', 'name')
const context = {
body: body?.context as unknown as FieldEditorContext,
}
if (changedField?.newValue !== changedField?.oldValue) {
currentArticleType.value?.onDeselected?.(ticket.value, context)
currentArticleType.value?.onDeselected?.(ticket.value, form.value)
}
if (!changedField?.newValue) return
@@ -514,7 +509,7 @@ export const useTicketEditForm = (
if (!newType) return
if (!formNode.context?._open) {
newType.onSelected?.(ticket.value, context, form.value)
newType.onSelected?.(ticket.value, form.value)
}
currentArticleType.value = newType
@@ -537,11 +532,7 @@ export const useTicketEditForm = (
if (!articleType) return
const body = formNode.find('body', 'name') as FormKitNode
const context = {
body: body.context as unknown as FieldEditorContext,
}
articleType.onOpened?.(ticket.value, context, form.value)
articleType.onOpened?.(ticket.value, form.value)
})
}

View File

@@ -1,88 +0,0 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { watch, type WatchHandle } from 'vue'
import type { FieldEditorContext } from '#shared/components/Form/fields/FieldEditor/types.ts'
import type {
FormHandler,
FormHandlerFunction,
FormValues,
ChangedField,
} from '#shared/components/Form/types.ts'
import { FormHandlerExecution } from '#shared/components/Form/types.ts'
/**
*
* @param senderTypeName 'email' | 'email-out'
* Ticket detail view expects 'email' as articleType for incoming emails
* Ticket create form expects 'email-out' as articleType for outgoing emails
*/
export const useTicketSignature = (senderTypeName: 'email' | 'email-out' = 'email-out') => {
const getValue = (values: FormValues, changedField: ChangedField | undefined, name: string) => {
return changedField?.name === name ? changedField.newValue : values[name]
}
let signatureWatcher: WatchHandle | null = null
const cleanUpSignatureHandler = () => {
if (!signatureWatcher) return
signatureWatcher.stop()
signatureWatcher = null
}
const signatureHandling = (editorName: string): FormHandler => {
const handleSignature: FormHandlerFunction = (execution, reactivity, data) => {
const { formNode, values, changedField } = data
if (
execution === FormHandlerExecution.FieldChange &&
changedField?.name !== 'group_id' &&
changedField?.name !== 'articleSenderType'
)
return
const editorContext = formNode?.find(editorName, 'name')?.context as
| FieldEditorContext
| undefined
if (!editorContext) return
const groupId = getValue(values, changedField, 'group_id')
// email-out -> articleSenderType -> form field for ticket create
// article.articleType -> used when new ticket article is added in ticket detail view
const senderType =
getValue(values, changedField, 'articleSenderType') ||
(values?.article as Record<'articleType', string>)?.articleType
cleanUpSignatureHandler()
if (!groupId || senderType !== senderTypeName) {
editorContext.removeSignature()
return
}
signatureWatcher = watch(
() => editorContext.signature,
(signature) => {
if (!signature) return editorContext.removeSignature()
editorContext.addSignature({
renderedBody: signature.renderedBody,
internalId: signature.internalId,
})
},
{ flush: 'post' },
)
}
return {
execution: [FormHandlerExecution.Initial, FormHandlerExecution.FieldChange],
callback: handleSignature,
}
}
return {
signatureHandling,
}
}

View File

@@ -43,11 +43,6 @@ RSpec.describe 'Desktop > Ticket > Editor and Advanced Features', app: :desktop_
Setting.set('time_accounting_types', true)
Setting.set('time_accounting_unit', 'minute')
# Activating full quote makes the editor add the signature for inline
# quotes as a side effect, which positions the cursor correctly above the
# quoted block instead of inside it.
Setting.set('ui_ticket_zoom_article_email_full_quote', true)
support_type
consulting_type
text_module
@@ -103,6 +98,48 @@ RSpec.describe 'Desktop > Ticket > Editor and Advanced Features', app: :desktop_
expect_subscriber_avatar(agent2)
end
# An inline quote gets the signature above it, a full quote below it (zammad#2319).
it 'adds the signature after an inline quote' do
cite_article_text(first_cite)
wait_for_test_flag('editor.signatureAdd')
editor = find_editor('Text')
wait_for_editor_ready(editor)
within(reply_form) do
expect(page).to have_css('[data-signature="true"]', text: agent1.fullname)
end
expect(editor_content.index('data-signature')).to be > editor_content.index('<blockquote')
end
# In the default configuration (no full quote) a reply without a selection opens with an
# empty body, so the signature handling is the only thing that fills the editor.
it 'adds the signature when replying without a selection and without full quote' do
within "#article-#{article.id}" do
find('button', exact_text: 'Reply', visible: :all).click
end
wait_for_test_flag('editor.signatureAdd')
editor = find_editor('Text')
wait_for_editor_ready(editor)
within(reply_form) do
expect(page).to have_css('[data-signature="true"]', text: agent1.fullname)
expect(page).to have_no_css('blockquote')
end
wait_for_test_flag(editor_test_flag(editor, 'focused'))
editor.input_element.send_keys('Reply above the signature.')
# The cursor has to sit above the signature, not inside or after it.
expect(reply_form).to have_css(
'p:first-of-type', text: 'Reply above the signature.'
)
end
# TipTap on macOS does not bind Cmd+Up / Cmd+Down to "start/end of
# document", so sending those shortcuts leaves the caret wherever the click
# landed. Drive ProseMirror's selection directly via the DOM Selection API.
@@ -195,6 +232,10 @@ RSpec.describe 'Desktop > Ticket > Editor and Advanced Features', app: :desktop_
find('#ticketArticleReplyForm')
end
def editor_content
page.evaluate_script(%(document.querySelector('#ticketArticleReplyForm [role="textbox"]').innerHTML))
end
# Click an editor toolbar action by its accessible label, falling back to the
# "Overflow menu" popover when the toolbar wraps and the action is not
# rendered directly. The popover items appear in the body, outside the form.