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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const fieldDefinition = createInput(
|
||||
'customerId',
|
||||
'organizationId',
|
||||
'signature',
|
||||
'signatureEnabled',
|
||||
'meta',
|
||||
'contentType',
|
||||
'inline',
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user