From ea676a55f6995ec636ec676a23b059c94eadc40e Mon Sep 17 00:00:00 2001 From: ogp-weeloong <135598754+ogp-weeloong@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:58:22 +0000 Subject: [PATCH] [EMAIL-PREVIEW-2] PLU-386: Enable RichTextEditor to substitute variables (#1653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Problem We want pipe owners to be able to preview their email across a variety of email clients (Outlook, Yahoo etc). # High-level Approach See #1652 for more details. # This PR Enables RichTextEditor to substitute variables with real values. It walks the DOM tree and: - Replaces `variable` / `tableVariable` spans with their resolved value (from `varInfo`, falling back to `data-value`) - Applies `simpleSubstitute` to any remaining text nodes to clean up legacy `{{step.…}}` patterns # Tests Only regression tests as this is a no-op. - [ ] Check that I can still compose postman emails with variables in the rich text editor - [ ] Check that I can still view email with actual values past executions Full test in later PR --- .../components/RichTextEditor/utils.test.ts | 47 ++++++++++++++++- .../src/components/RichTextEditor/utils.ts | 50 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/RichTextEditor/utils.test.ts b/packages/frontend/src/components/RichTextEditor/utils.test.ts index 049e39a1e1..b025fe741d 100644 --- a/packages/frontend/src/components/RichTextEditor/utils.test.ts +++ b/packages/frontend/src/components/RichTextEditor/utils.test.ts @@ -1,7 +1,11 @@ import escapeHTML from 'escape-html' import { describe, expect, it } from 'vitest' -import { removeProblematicWhitespace, substituteOldTemplates } from './utils' +import { + removeProblematicWhitespace, + substituteForPreview, + substituteOldTemplates, +} from './utils' const varInfo = new Map< string, @@ -169,6 +173,47 @@ describe('replaceOldTemplates', () => { }) }) +describe('substituteForPreview', () => { + it('replaces a variable span with its resolved value from varInfo', () => { + const input = + 'Hi {{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.hello}}!' + expect(substituteForPreview(input, varInfo)).toEqual('Hi world!') + }) + + it('replaces a table-variable span with its resolved value', () => { + const input = + '
{{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.hello|abcd}}
' + expect(substituteForPreview(input, varInfo)).toEqual('world') + }) + + it('substitutes legacy {{step.…}} patterns in plain text nodes', () => { + const input = + '

Aloha {{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.hello}}, meet {{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.papa}}.

' + expect(substituteForPreview(input, varInfo)).toEqual( + '

Aloha world, meet mama.

', + ) + }) + + it('falls back to data-value when var is missing from varInfo', () => { + const input = + '{{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.missing}}' + expect(substituteForPreview(input, varInfo)).toEqual('stale-fallback') + }) + + it('falls back to empty string when both varInfo and data-value are missing', () => { + const input = + '{{step.ff5000f5-021c-4488-b6c2-c582c42ba3cf.missing}}' + expect(substituteForPreview(input, varInfo)).toEqual('') + }) + + it('returns empty string for nullish input', () => { + const inputs = [undefined, null] as unknown as string[] + for (const input of inputs) { + expect(substituteForPreview(input, varInfo)).toEqual('') + } + }) +}) + describe('removeProblematicWhitespace', () => { it('should remove non-breaking space', () => { const input = 'Lorem​Ipsum​Dolor​Sit​Amet​Co' diff --git a/packages/frontend/src/components/RichTextEditor/utils.ts b/packages/frontend/src/components/RichTextEditor/utils.ts index 43de64b1ca..58dd3d7b4e 100644 --- a/packages/frontend/src/components/RichTextEditor/utils.ts +++ b/packages/frontend/src/components/RichTextEditor/utils.ts @@ -151,6 +151,56 @@ export function substituteOldTemplates( return substitutedDom.outerHTML } +/** + * Renders RichTextEditor HTML to plain final-form HTML, suitable for feeding + * into an email preview pipeline. + * + * - Variable / table-variable spans are replaced with their resolved value: + * prefer the entry in `varInfo`, fall back to the node's own `data-value` + * attribute (kept current by `recursiveSubstitute`). + * - Legacy `{{step.…}}` patterns remaining in plain text nodes are resolved + * via `simpleSubstitute`. + */ +export function substituteForPreview( + html: string, + varInfo: VariableInfoMap, +): string { + if (!html) { + return '' + } + + const root = parse(html) + + function processChildren(parent: NodeHTMLElement): void { + const newChildren: Node[] = [] + for (const child of parent.childNodes) { + if (child instanceof NodeHTMLElement) { + const dataType = child.getAttribute('data-type') + const dataId = child.getAttribute('data-id') + if ( + (dataType === 'variable' || dataType === 'tableVariable') && + dataId != null + ) { + const fromMap = varInfo.get(`{{${dataId}}}`)?.testRunValue + const fallback = child.getAttribute('data-value') ?? '' + newChildren.push(new TextNode(fromMap ?? fallback)) + continue + } + processChildren(child) + newChildren.push(child) + } else if (child instanceof TextNode) { + newChildren.push(new TextNode(simpleSubstitute(child.rawText, varInfo))) + } else { + newChildren.push(child) + } + } + parent.childNodes = newChildren + } + + processChildren(root) + return root.outerHTML +} + export function getPopoverPlacement( editor: Editor | null, ): PlacementWithLogical {