feat: Expression editor - ability to preview HTML or Markdown in results pane (#21408)

This commit is contained in:
Michael Kret 2025-11-11 16:00:35 +02:00 committed by GitHub
parent cf9eb4e4ef
commit c8a29a77f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1300 additions and 19 deletions

View File

@ -2766,6 +2766,9 @@
"ndv.search.noMatchSchema.description": "To search field values, switch to table or JSON view. {link}",
"ndv.search.noMatchSchema.description.link": "Clear filter",
"ndv.search.items": "{matched} of {count} item | {matched} of {count} items",
"ndv.render.text": "Text",
"ndv.render.html": "Html",
"ndv.render.markdown": "Markdown",
"ndv.nodeHints.disabled": "This node is disabled, and will simply pass the input through",
"ndv.nodeHints.alwaysOutputData": "This node will output an empty item if nothing would normally be returned",
"ndv.nodeHints.alwaysOutputData.short": "output an empty item if nothing would normally be returned",

View File

@ -81,4 +81,103 @@ describe('ExpressionEditModal', () => {
expect(editor).toHaveAttribute('aria-readonly', 'true');
});
});
describe('output render mode radio buttons', () => {
it('renders all three render mode options', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
expect(getByText('Text')).toBeInTheDocument();
expect(getByText('Html')).toBeInTheDocument();
expect(getByText('Markdown')).toBeInTheDocument();
});
});
it('has Text as default render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
const textButton = getByText('Text').closest('label');
expect(textButton).toHaveAttribute('aria-checked', 'true');
});
});
it('allows switching to Html render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(async () => {
const htmlButton = getByText('Html').closest('label');
const htmlInput = htmlButton?.querySelector('input');
if (htmlInput) {
htmlInput.click();
expect(htmlInput).toBeChecked();
}
});
});
it('allows switching to Markdown render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(async () => {
const markdownButton = getByText('Markdown').closest('label');
const markdownInput = markdownButton?.querySelector('input');
if (markdownInput) {
markdownInput.click();
expect(markdownInput).toBeChecked();
}
});
});
it('has correct values for each render mode option', async () => {
const { getByTestId } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
expect(getByTestId('radio-button-text')).toBeInTheDocument();
expect(getByTestId('radio-button-html')).toBeInTheDocument();
expect(getByTestId('radio-button-markdown')).toBeInTheDocument();
});
});
});
});

View File

@ -25,7 +25,14 @@ import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
import { useThrottleFn } from '@vueuse/core';
import { ElDialog } from 'element-plus';
import { N8nIcon, N8nInput, N8nResizeWrapper, N8nText, type ResizeData } from '@n8n/design-system';
import {
N8nIcon,
N8nInput,
N8nRadioButtons,
N8nResizeWrapper,
N8nText,
type ResizeData,
} from '@n8n/design-system';
const DEFAULT_LEFT_SIDEBAR_WIDTH = 360;
type Props = {
@ -64,6 +71,7 @@ const sidebarWidth = ref(DEFAULT_LEFT_SIDEBAR_WIDTH);
const expressionInputRef = ref<InstanceType<typeof ExpressionEditorModalInput>>();
const expressionResultRef = ref<InstanceType<typeof ExpressionOutput>>();
const theme = outputTheme();
const outputRenderMode = ref<'text' | 'html' | 'markdown'>('text');
const activeNode = computed(() => ndvStore.activeNode);
const inputEditor = computed(() => expressionInputRef.value?.editor);
@ -216,7 +224,18 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<N8nText bold size="large">
{{ i18n.baseText('parameterInput.result') }}
</N8nText>
<OutputItemSelect />
<div :class="$style.headerControls">
<OutputItemSelect />
<N8nRadioButtons
v-model="outputRenderMode"
size="small"
:options="[
{ label: i18n.baseText('ndv.render.text'), value: 'text' },
{ label: i18n.baseText('ndv.render.html'), value: 'html' },
{ label: i18n.baseText('ndv.render.markdown'), value: 'markdown' },
]"
/>
</div>
</div>
<div :class="[$style.editorContainer, { 'ph-no-capture': redactValues }]">
@ -225,6 +244,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
:class="$style.editor"
:segments="segments"
:extensions="theme"
:render="outputRenderMode"
data-test-id="expression-modal-output"
/>
</div>
@ -318,6 +338,14 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
gap: var(--spacing--5xs);
}
.headerControls {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: var(--spacing--4xs);
}
.tip {
min-height: 22px;
}

View File

@ -0,0 +1,349 @@
import { createTestingPinia } from '@pinia/testing';
import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue';
import { renderComponent } from '@/__tests__/render';
describe('RunDataMarkdown.vue', () => {
it('should render markdown content correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# Hello World\n\nThis is a test.',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain('Hello World');
expect(markdownContainer?.textContent).toContain('This is a test.');
});
it('should render headers correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
const h1 = markdownContainer?.querySelector('h1');
const h2 = markdownContainer?.querySelector('h2');
const h3 = markdownContainer?.querySelector('h3');
const h4 = markdownContainer?.querySelector('h4');
const h5 = markdownContainer?.querySelector('h5');
const h6 = markdownContainer?.querySelector('h6');
expect(h1?.textContent).toBe('H1');
expect(h2?.textContent).toBe('H2');
expect(h3?.textContent).toBe('H3');
expect(h4?.textContent).toBe('H4');
expect(h5?.textContent).toBe('H5');
expect(h6?.textContent).toBe('H6');
});
it('should render bold and italic text', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '**bold text** and *italic text*',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
const strong = markdownContainer?.querySelector('strong');
const em = markdownContainer?.querySelector('em');
expect(strong?.textContent).toBe('bold text');
expect(em?.textContent).toBe('italic text');
});
it('should render links correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '[Click here](https://example.com)',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const link = markdownContainer?.querySelector('a');
expect(link).toBeInTheDocument();
expect(link?.textContent).toBe('Click here');
expect(link?.getAttribute('href')).toBe('https://example.com');
});
it('should render code blocks correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '```javascript\nconst x = 42;\n```',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const pre = markdownContainer?.querySelector('pre');
const code = pre?.querySelector('code');
expect(pre).toBeInTheDocument();
expect(code).toBeInTheDocument();
expect(code?.textContent).toContain('const x = 42;');
});
it('should render inline code correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Use `console.log()` for debugging',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const code = markdownContainer?.querySelector('code');
expect(code).toBeInTheDocument();
expect(code?.textContent).toBe('console.log()');
});
it('should render unordered lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '- Item 1\n- Item 2\n- Item 3',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const ul = markdownContainer?.querySelector('ul');
const listItems = ul?.querySelectorAll('li');
expect(ul).toBeInTheDocument();
expect(listItems?.length).toBe(3);
expect(listItems?.[0].textContent).toBe('Item 1');
expect(listItems?.[1].textContent).toBe('Item 2');
expect(listItems?.[2].textContent).toBe('Item 3');
});
it('should render ordered lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '1. First\n2. Second\n3. Third',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const ol = markdownContainer?.querySelector('ol');
const listItems = ol?.querySelectorAll('li');
expect(ol).toBeInTheDocument();
expect(listItems?.length).toBe(3);
expect(listItems?.[0].textContent).toBe('First');
expect(listItems?.[1].textContent).toBe('Second');
expect(listItems?.[2].textContent).toBe('Third');
});
it('should render blockquotes correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '> This is a quote\n> with multiple lines',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const blockquote = markdownContainer?.querySelector('blockquote');
expect(blockquote).toBeInTheDocument();
expect(blockquote?.textContent).toContain('This is a quote');
expect(blockquote?.textContent).toContain('with multiple lines');
});
it('should render tables correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const table = markdownContainer?.querySelector('table');
const headers = table?.querySelectorAll('th');
const cells = table?.querySelectorAll('td');
expect(table).toBeInTheDocument();
expect(headers?.length).toBe(2);
expect(headers?.[0].textContent).toBe('Header 1');
expect(headers?.[1].textContent).toBe('Header 2');
expect(cells?.length).toBe(2);
expect(cells?.[0].textContent).toContain('Cell 1');
expect(cells?.[1].textContent).toContain('Cell 2');
});
it('should render horizontal rules correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Before\n\n---\n\nAfter',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const hr = markdownContainer?.querySelector('hr');
expect(hr).toBeInTheDocument();
});
it('should render empty string', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
});
it('should render plain text without markdown syntax', () => {
const plainText = 'This is just plain text without any markdown';
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: plainText,
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain(plainText);
});
it('should handle complex mixed markdown content', () => {
const complexMarkdown = `# Main Title
This is a paragraph with **bold** and *italic* text.
## Subsection
- List item 1
- List item 2
- Nested item
\`\`\`javascript
const code = "example";
\`\`\`
> A quote
[Link](https://example.com)`;
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: complexMarkdown,
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.querySelector('h1')).toBeInTheDocument();
expect(markdownContainer?.querySelector('h2')).toBeInTheDocument();
expect(markdownContainer?.querySelector('strong')).toBeInTheDocument();
expect(markdownContainer?.querySelector('em')).toBeInTheDocument();
expect(markdownContainer?.querySelector('ul')).toBeInTheDocument();
expect(markdownContainer?.querySelector('pre')).toBeInTheDocument();
expect(markdownContainer?.querySelector('blockquote')).toBeInTheDocument();
expect(markdownContainer?.querySelector('a')).toBeInTheDocument();
});
it('should apply markdown CSS module class', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# Test',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.className).toContain('markdown');
});
it('should handle markdown with special characters', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Text with < > & " special characters',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain('special characters');
});
it('should render markdown with newlines correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Line 1\n\nLine 2\n\nLine 3',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const paragraphs = markdownContainer?.querySelectorAll('p');
expect(markdownContainer).toBeInTheDocument();
expect(paragraphs?.length).toBeGreaterThan(0);
});
it('should handle image markdown syntax', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '![Alt text](https://example.com/image.png)',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const img = markdownContainer?.querySelector('img');
expect(img).toBeInTheDocument();
expect(img?.getAttribute('alt')).toBe('Alt text');
expect(img?.getAttribute('src')).toBe('https://example.com/image.png');
});
it('should render nested lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '- Item 1\n - Nested 1\n - Nested 2\n- Item 2',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const lists = markdownContainer?.querySelectorAll('ul');
expect(lists && lists.length > 0).toBe(true);
});
it('should render strikethrough text if supported', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '~~strikethrough~~',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
});
});

View File

@ -0,0 +1,183 @@
<script lang="ts">
import { defineComponent } from 'vue';
import VueMarkdownRender from 'vue-markdown-render';
export default defineComponent({
name: 'RunDataMarkdown',
components: {
VueMarkdownRender,
},
props: {
inputMarkdown: {
type: String,
required: true,
},
},
});
</script>
<template>
<div :class="$style.markdown">
<VueMarkdownRender :source="inputMarkdown" />
</div>
</template>
<style lang="scss" module>
.markdown {
width: 100%;
height: 100%;
overflow: auto;
padding: var(--spacing--sm) var(--spacing--md);
border: var(--border);
border-radius: var(--radius);
background-color: var(--color--background--light-3);
color: var(--color--text);
font-family: var(--font-family);
line-height: var(--line-height--xl);
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: var(--spacing--lg);
margin-bottom: var(--spacing--sm);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--lg);
border-bottom: var(--border-width) solid var(--border-color--light);
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: var(--font-size--2xl);
}
h2 {
font-size: var(--font-size--xl);
}
h3 {
font-size: var(--font-size--lg);
}
h4 {
font-size: var(--font-size--md);
}
h5 {
font-size: var(--font-size--sm);
}
h6 {
font-size: var(--font-size--xs);
color: var(--color--text--tint-1);
}
p {
margin: var(--spacing--sm) 0;
}
a {
color: var(--color--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
color: var(--color--primary--shade-1);
}
}
strong {
font-weight: var(--font-weight--bold);
}
em {
font-style: italic;
}
blockquote {
padding: var(--spacing--xs) var(--spacing--sm);
margin: var(--spacing--sm) 0;
color: var(--color--text--tint-1);
border-left: 0.25em solid var(--border-color);
background-color: var(--color--background--light-1);
border-radius: var(--radius--sm);
}
code,
pre {
font-family: var(--font-family--monospace);
font-size: var(--font-size--sm);
}
code {
background-color: var(--color--background--light-1);
padding: 0.2em 0.4em;
border-radius: var(--radius--sm);
color: var(--code--color--foreground);
}
pre {
background-color: var(--color--background--light-2);
padding: var(--spacing--sm);
border-radius: var(--radius--lg);
overflow-x: auto;
border: var(--border-width) solid var(--border-color--light);
code {
background: none;
padding: 0;
color: inherit;
}
}
ul,
ol {
margin: var(--spacing--sm) 0;
padding-left: var(--spacing--lg);
}
table {
border-collapse: collapse;
width: 100%;
margin: var(--spacing--sm) 0;
font-size: var(--font-size--sm);
th,
td {
border: var(--border-width) solid var(--border-color--light);
padding: var(--spacing--2xs) var(--spacing--xs);
text-align: left;
}
th {
background-color: var(--table--header--color--background);
font-weight: var(--font-weight--medium);
color: var(--color--text--shade-1);
}
tr:nth-child(even) {
background-color: var(--table--row--color--background--even);
}
tr:hover {
background-color: var(--table--row--color--background--hover);
}
}
hr {
border: 0;
border-top: var(--border-width) solid var(--border-color--light);
margin: var(--spacing--lg) 0;
}
img {
max-width: 100%;
border-radius: var(--radius--sm);
box-shadow: var(--shadow--light);
}
blockquote > :last-child {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,569 @@
import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import type { Extension } from '@codemirror/state';
import ExpressionOutput from './ExpressionOutput.vue';
import type { Segment } from '../../../../../app/types/expressions';
describe('ExpressionOutput.vue', () => {
const basicSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Hello ',
},
{
kind: 'resolvable',
from: 6,
to: 16,
resolvable: '{{ $json.name }}',
resolved: 'World',
state: 'valid',
error: null,
},
];
describe('render mode: text', () => {
it('should render text output by default', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.textContent).toContain('Hello World');
});
it('should render empty string message when segments are empty', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: [],
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('[empty]');
});
it('should render plaintext segments correctly', () => {
const plaintextSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 5,
plaintext: 'Test ',
},
{
kind: 'plaintext',
from: 5,
to: 10,
plaintext: 'Value',
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: plaintextSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Test Value');
});
it('should render resolvable segments with resolved values', () => {
const resolvableSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ 1 + 1 }}',
resolved: 2,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: resolvableSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('2');
});
it('should handle boolean resolved values', () => {
const booleanSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ true }}',
resolved: true,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: booleanSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('true');
});
it('should skip duplicate segments', () => {
const duplicateSegments: Segment[] = [
{
from: 0,
to: 5,
plaintext: '[1,2]',
kind: 'plaintext',
},
{
from: 0,
to: 1,
plaintext: '[',
kind: 'plaintext',
},
{
from: 1,
to: 2,
plaintext: '1',
kind: 'plaintext',
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: duplicateSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('[1,2]');
});
});
describe('render mode: html', () => {
it('should render HTML content when render mode is html', () => {
const htmlSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.html }}',
resolved: '<h1>Hello</h1><p>World</p>',
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: htmlSegments,
render: 'html',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.tagName).toBe('IFRAME');
});
it('should not render CodeMirror editor in html mode', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
});
describe('render mode: markdown', () => {
it('should render Markdown content when render mode is markdown', () => {
const markdownSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.markdown }}',
resolved: '# Hello\n\nThis is **bold** text',
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: markdownSegments,
render: 'markdown',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output).toHaveClass('markdown');
});
it('should not render CodeMirror editor in markdown mode', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
});
describe('switching render modes', () => {
it('should switch from text to html mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
await rerender({
segments: basicSegments,
render: 'html',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
it('should switch from text to markdown mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
await rerender({
segments: basicSegments,
render: 'markdown',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
it('should switch from html to text mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
await rerender({
segments: basicSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).not.toBe('IFRAME');
});
it('should switch from markdown to text mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
await rerender({
segments: basicSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).not.toHaveClass('markdown');
});
it('should switch from html to markdown mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
await rerender({
segments: basicSegments,
render: 'markdown',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).not.toBe('IFRAME');
});
it('should switch from markdown to html mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
await rerender({
segments: basicSegments,
render: 'html',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).not.toHaveClass('markdown');
});
it('should update segments when in text mode', async () => {
const initialSegments = [
{
kind: 'plaintext',
from: 0,
to: 5,
plaintext: 'First',
},
] as Segment[];
const updatedSegments = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Second',
},
] as Segment[];
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: initialSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('First');
await rerender({
segments: updatedSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Second');
});
});
it('should handle rapid mode switching', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
await rerender({ segments: basicSegments, render: 'html' });
await rerender({ segments: basicSegments, render: 'markdown' });
await rerender({ segments: basicSegments, render: 'text' });
await waitFor(() => {
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(container.querySelector('iframe')).not.toBeInTheDocument();
});
});
describe('getValue expose method', () => {
it('should render output correctly for getValue usage', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.textContent).toContain('Hello World');
});
});
describe('edge cases', () => {
it('should handle segments with null resolved value', () => {
const segmentsWithNull: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.missing }}',
resolved: null,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: segmentsWithNull,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
});
it('should handle mixed plaintext and resolvable segments', () => {
const mixedSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Hello ',
},
{
kind: 'resolvable',
from: 6,
to: 16,
resolvable: '{{ $json.name }}',
resolved: 'John',
state: 'valid',
error: null,
},
{
kind: 'plaintext',
from: 16,
to: 23,
plaintext: ', age: ',
},
{
kind: 'resolvable',
from: 23,
to: 33,
resolvable: '{{ $json.age }}',
resolved: 25,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: mixedSegments,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Hello John, age: 25');
});
it('should handle custom extensions in text mode', () => {
const customExtensions: Extension[] = [];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
extensions: customExtensions,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
});
});
});

View File

@ -4,16 +4,23 @@ import { EditorView } from '@codemirror/view';
import { useI18n } from '@n8n/i18n';
import { highlighter } from '../../plugins/codemirror/resolvableHighlighter';
import type { Plaintext, Resolved, Segment } from '@/app/types/expressions';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { forceParse } from '@/app/utils/forceParse';
import RunDataHtml from '@/features/ndv/runData/components/RunDataHtml.vue';
import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue';
interface ExpressionOutputProps {
segments: Segment[];
extensions?: Extension[];
render?: 'text' | 'html' | 'markdown';
}
const props = withDefaults(defineProps<ExpressionOutputProps>(), { extensions: () => [] });
const props = withDefaults(defineProps<ExpressionOutputProps>(), {
extensions: () => [],
render: 'text',
});
const i18n = useI18n();
@ -75,21 +82,9 @@ const resolvedSegments = computed<Resolved[]>(() => {
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
});
watch(
() => props.segments,
() => {
if (!editor.value) return;
function initializeEditor() {
if (!root.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
);
onMounted(() => {
editor.value = new EditorView({
parent: root.value as HTMLElement,
state: EditorState.create({
@ -105,6 +100,38 @@ onMounted(() => {
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
}
watch(
() => props.segments,
() => {
if (props.render !== 'text' || !editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
);
watch(
() => props.render,
async (newMode) => {
if (newMode === 'text' && !editor.value) {
await nextTick();
initializeEditor();
} else if ((newMode === 'html' || newMode === 'markdown') && editor.value) {
editor.value.destroy();
editor.value = null;
}
},
);
onMounted(() => {
if (props.render !== 'text') return;
initializeEditor();
});
onBeforeUnmount(() => {
@ -115,5 +142,28 @@ defineExpose({ getValue: () => '=' + resolvedExpression.value });
</script>
<template>
<div ref="root" data-test-id="expression-output"></div>
<div v-if="render === 'text'" ref="root" data-test-id="expression-output"></div>
<RunDataHtml
v-else-if="render === 'html'"
data-test-id="expression-output"
:input-html="resolvedExpression"
/>
<RunDataMarkdown
v-else-if="render === 'markdown'"
data-test-id="expression-output"
:input-markdown="resolvedExpression"
/>
</template>
<style lang="scss">
.__html-display {
border: 2px solid var(--border-color);
padding: var(--spacing--xs);
border-width: var(--border-width);
border-style: var(--input--border-style, var(--border-style));
border-color: var(--input--border-color, var(--border-color));
border-radius: var(--input--radius, var(--radius));
}
</style>