chore: insert resolved variable value in reply editor (#14921)

# 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
This commit is contained in:
Sivin Varghese
2026-07-10 17:24:34 +05:30
committed by GitHub
parent 848e94bcf2
commit 03a1b1dbc1
7 changed files with 275 additions and 15 deletions
@@ -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,
}),