diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d289654b509..6685ef36bf8 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts index 4e47ce1c486..ca97a820dcd 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts @@ -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(); + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue index 2022bd26e9c..bc87044ea80 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue @@ -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>(); const expressionResultRef = ref>(); 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); {{ i18n.baseText('parameterInput.result') }} - +
+ + +
@@ -225,6 +244,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10); :class="$style.editor" :segments="segments" :extensions="theme" + :render="outputRenderMode" data-test-id="expression-modal-output" />
@@ -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; } diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts new file mode 100644 index 00000000000..094f6051ff1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue new file mode 100644 index 00000000000..d5b46411cc7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts new file mode 100644 index 00000000000..0c0f445777b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts @@ -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: '

Hello

World

', + 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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue index 8a5bc54cf8c..cb7982db17a 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue +++ b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue @@ -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(), { extensions: () => [] }); +const props = withDefaults(defineProps(), { + extensions: () => [], + render: 'text', +}); const i18n = useI18n(); @@ -75,21 +82,9 @@ const resolvedSegments = computed(() => { .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 }); + +