mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
feat: Expression editor - ability to preview HTML or Markdown in results pane (#21408)
This commit is contained in:
parent
cf9eb4e4ef
commit
c8a29a77f2
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user