From 03a1b1dbc14094aaf1b62c7d7ebd0b6915a2c56a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:24:34 +0530 Subject: [PATCH] chore: insert resolved variable value in reply editor (#14921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR makes reply editor variables insert their resolved value (for example, the contact's name) instead of the raw `{{contact.name}}` placeholder, matching canned response behavior. This works both when picking a variable from the `{{` menu and when an agent manually types out `{{contact.name}}` — it resolves the moment the closing `}}` is typed. If a variable has no value, the `{{placeholder}}` is kept so the backend can still resolve it when the message is sent. Private notes are left untouched. For safety, a resolved value that itself contains Liquid syntax `({{ }}` or `{% %})` also keeps its placeholder, so customer-controlled fields can never inject Liquid into the outgoing message. Fixes https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Open a conversation and add a reply. 2. Type `{{` and pick a variable that has a value (e.g. Contact name) → it inserts the actual value. 3. Manually type `{{contact.name}}` and close the braces → it auto-resolves to the value. 4. Insert/type a variable with no value → the `{{placeholder}}` stays; confirm it resolves correctly on send. 5. Repeat in a private note → placeholders are left as-is. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../components/widgets/WootWriter/Editor.vue | 5 + .../widgets/conversation/ReplyBox.vue | 10 +- .../dashboard/helper/editorHelper.js | 60 ++++++- .../helper/specs/editorContentHelper.spec.js | 58 +++++-- .../helper/specs/editorHelper.spec.js | 149 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 7 +- 7 files changed, 275 insertions(+), 15 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 09dc23819..634276361 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -62,6 +62,7 @@ import { calculateMenuPosition, getEffectiveChannelType, stripUnsupportedFormatting, + createVariableInputRule, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -306,6 +307,10 @@ const plugins = computed(() => { searchTerm: variableSearchTerm, isAllowed: () => !props.isPrivate, }), + createVariableInputRule({ + isPrivate: () => props.isPrivate, + getVariables: () => props.variables, + }), createSuggestionPlugin({ trigger: ':', minChars: 2, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 6af876cc6..471d10f3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -48,6 +48,8 @@ import { appendSignature, removeSignature, getEffectiveChannelType, + getAgentVariables, + getContactVariables, } from 'dashboard/helper/editorHelper'; import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; import { useKbd } from 'dashboard/composables/utils/useKbd'; @@ -393,7 +395,13 @@ export default { contact: this.currentContact, inbox: this.inbox, }); - return variables; + // Match the backend drops: names are Ruby-capitalized and + // {{agent.*}} is the message sender, not the assignee. + return { + ...variables, + ...getContactVariables(this.currentContact), + ...getAgentVariables(this.currentUser), + }; }, connectedPortalSlug() { const { help_center: portal = {} } = this.inbox; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 32f56172a..2d3c75777 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue'; import camelcaseKeys from 'camelcase-keys'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; +import { InputRule, inputRules } from 'prosemirror-inputrules'; /** * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc. @@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) { * - emoji */ +// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send. +const LIQUID_SYNTAX = /\{\{|\{%/; + +// Value when set (and not itself Liquid), else the {{placeholder}} for the backend. +export const resolveVariableText = (key, variables) => { + const value = String(variables?.[key] ?? ''); + return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`; +}; + +// Name variables normalized like the backend drops (UserDrop/ContactDrop): +// name split on whitespace, each word Ruby-capitalized (rest downcased). +const getNameVariables = (prefix, name) => { + const names = (name || '') + .split(/\s+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + return { + [`${prefix}.name`]: names.join(' '), + [`${prefix}.first_name`]: names[0] || '', + [`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '', + }; +}; + +// {{agent.*}} values for the message sender. +export const getAgentVariables = user => ({ + ...getNameVariables('agent', user.name), + 'agent.email': user.email, +}); + +// {{contact.*}} name values. +export const getContactVariables = contact => + getNameVariables('contact', contact?.name); + +// Resolves a manually typed {{variable}} to its value on the closing braces. +// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note. +export const createVariableInputRule = ({ isPrivate, getVariables }) => { + const rule = new InputRule( + /\{\{([^{}]+)\}\}$/, + (editorState, match, from, to) => { + if (isPrivate()) return null; + const [, key] = match; + const text = resolveVariableText(key, getVariables()); + if (text === `{{${key}}}`) return null; + return editorState.tr.insertText(text, from, to); + } + ); + return inputRules({ rules: [rule] }); +}; + /** * Centralized node creation function that handles the creation of different types of nodes based on the specified type. * @param {Object} editorView - The editor view instance. @@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => { ); } case 'variable': - return state.schema.text(`{{${content}}}`); + return state.schema.text(content); case 'emoji': return state.schema.text(content); case 'tool': { @@ -497,8 +547,12 @@ const nodeCreators = { to, }; }, - variable: (editorView, content, from, to) => ({ - node: createNode(editorView, 'variable', content), + variable: (editorView, content, from, to, variables) => ({ + node: createNode( + editorView, + 'variable', + resolveVariableText(content, variables) + ), from, to, }), diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js index 4efb4d1d9..57d8bd533 100644 --- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js @@ -94,16 +94,56 @@ describe('getContentNode', () => { }); describe('getVariableNode', () => { - it('should create a variable node', () => { - const content = 'name'; - const from = 0; - const to = 10; - getContentNode(editorView, 'variable', content, { - from, - to, - }); + it('should render the resolved value directly when the variable has a value', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': 'John' } + ); - expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}'); + expect(editorView.state.schema.text).toHaveBeenCalledWith('John'); + }); + + it('should resolve camelCase custom attributes and non-string values', () => { + getContentNode( + editorView, + 'variable', + 'contact.custom_attribute.cloudCustomer', + { from: 0, to: 10 }, + { 'contact.custom_attribute.cloudCustomer': true } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith('true'); + }); + + it('should keep the placeholder when the variable has no value', () => { + getContentNode( + editorView, + 'variable', + 'contact.email', + { from: 0, to: 10 }, + {} + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.email}}' + ); + }); + + it('should keep the placeholder when the value contains Liquid syntax', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': '{{agent.email}}' } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.name}}' + ); }); }); diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 220b9903e..fafe1bc56 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -11,9 +11,12 @@ import { calculateMenuPosition, cleanSignature, collapseSelection, + createVariableInputRule, extractTextFromMarkdown, findNodeToInsertImage, findSignatureInBody, + getAgentVariables, + getContactVariables, getContentNode, getFormattingForEditor, getMenuAnchor, @@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => { }); }); }); + +describe('getAgentVariables', () => { + it('builds agent variables from the user', () => { + expect( + getAgentVariables({ name: 'John Doe', email: 'john@example.com' }) + ).toEqual({ + 'agent.name': 'John Doe', + 'agent.first_name': 'John', + 'agent.last_name': 'Doe', + 'agent.email': 'john@example.com', + }); + }); + + it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => { + const variables = getAgentVariables({ name: 'JANE doE' }); + + expect(variables['agent.name']).toBe('Jane Doe'); + expect(variables['agent.first_name']).toBe('Jane'); + expect(variables['agent.last_name']).toBe('Doe'); + }); + + it('ignores extra whitespace between words', () => { + expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe( + 'John Doe' + ); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getAgentVariables({ name: 'john' }); + + expect(variables['agent.first_name']).toBe('John'); + expect(variables['agent.last_name']).toBe(''); + }); + + it('handles a missing name', () => { + const variables = getAgentVariables({ email: 'john@example.com' }); + + expect(variables['agent.name']).toBe(''); + expect(variables['agent.first_name']).toBe(''); + expect(variables['agent.last_name']).toBe(''); + }); +}); + +describe('getContactVariables', () => { + it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => { + expect(getContactVariables({ name: 'JANE doE' })).toEqual({ + 'contact.name': 'Jane Doe', + 'contact.first_name': 'Jane', + 'contact.last_name': 'Doe', + }); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getContactVariables({ name: 'john' }); + + expect(variables['contact.first_name']).toBe('John'); + expect(variables['contact.last_name']).toBe(''); + }); + + it('handles a missing contact', () => { + expect(getContactVariables(undefined)['contact.name']).toBe(''); + }); +}); + +describe('createVariableInputRule', () => { + // Editor holding `{{key}` so we can simulate typing the final `}`. + const buildView = (typed, { isPrivate = false, variables = {} } = {}) => { + const plugin = createVariableInputRule({ + isPrivate: () => isPrivate, + getVariables: () => variables, + }); + const state = EditorState.create({ + schema, + doc: schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text(typed)]), + ]), + plugins: [plugin], + }); + return new EditorView(document.body, { state }); + }; + + // Types the closing `}`; when the rule declines, insert it like the browser would. + const typeClosingBrace = view => { + const end = view.state.doc.content.size - 1; + const handled = view.someProp('handleTextInput', fn => + fn(view, end, end, '}') + ); + if (!handled) { + view.dispatch(view.state.tr.insertText('}', end, end)); + } + }; + + it('resolves a manually typed {{variable}} to its value on the closing brace', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('John'); + view.destroy(); + }); + + it('resolves boolean/non-string values', () => { + const view = buildView('{{contact.custom_attribute.cloudCustomer}', { + variables: { 'contact.custom_attribute.cloudCustomer': true }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('true'); + view.destroy(); + }); + + it('keeps the placeholder when the variable has no value', () => { + const view = buildView('{{contact.email}', { variables: {} }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.email}}'); + view.destroy(); + }); + + it('keeps the placeholder when the value itself contains Liquid syntax', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': '{{agent.email}}' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); + + it('does not resolve inside a private note', () => { + const view = buildView('{{contact.name}', { + isPrivate: true, + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); +}); diff --git a/package.json b/package.json index 917a1b97d..d964a30a4 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "opus-recorder": "^8.0.5", "pinia": "^3.0.4", "prosemirror-commands": "^1.7.1", + "prosemirror-inputrules": "^1.4.0", "prosemirror-schema-list": "^1.5.1", "qrcode": "^1.5.4", "semver": "7.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80fbf318a..0ffb85c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 + prosemirror-inputrules: + specifier: ^1.4.0 + version: 1.4.0 prosemirror-schema-list: specifier: ^1.5.1 version: 1.5.1 @@ -9037,7 +9040,7 @@ snapshots: prosemirror-state@1.4.3: dependencies: prosemirror-model: 1.22.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-tables@1.5.0: @@ -9065,7 +9068,7 @@ snapshots: dependencies: prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 proto-list@1.2.4: {}