mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Make search work for "rendered" display type (#16910)
This commit is contained in:
@@ -1885,7 +1885,12 @@ defineExpose({ enterEditMode });
|
||||
</Suspense>
|
||||
|
||||
<Suspense v-else-if="hasNodeRun && displayMode === 'ai'">
|
||||
<LazyRunDataAi render-type="rendered" :compact="compact" :content="parsedAiContent" />
|
||||
<LazyRunDataAi
|
||||
render-type="rendered"
|
||||
:compact="compact"
|
||||
:content="parsedAiContent"
|
||||
:search="search"
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
type NodeConnectionType,
|
||||
type Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { splitTextBySearch } from '@/utils/stringUtils';
|
||||
import { escapeHtml } from 'xss';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import { unescapeAll } from 'markdown-it/lib/common/utils';
|
||||
|
||||
export interface AIResult {
|
||||
node: string;
|
||||
@@ -198,3 +202,52 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
|
||||
|
||||
return tokenUsage;
|
||||
}
|
||||
|
||||
export function createHtmlFragmentWithSearchHighlight(
|
||||
text: string,
|
||||
search: string | undefined,
|
||||
): string {
|
||||
const escaped = escapeHtml(text);
|
||||
|
||||
return search
|
||||
? splitTextBySearch(escaped, search)
|
||||
.map((part) => (part.isMatched ? `<mark>${part.content}</mark>` : part.content))
|
||||
.join('')
|
||||
: escaped;
|
||||
}
|
||||
|
||||
export function createSearchHighlightPlugin(search: string | undefined) {
|
||||
return (md: MarkdownIt) => {
|
||||
md.renderer.rules.text = (tokens, idx) =>
|
||||
createHtmlFragmentWithSearchHighlight(tokens[idx].content, search);
|
||||
|
||||
md.renderer.rules.code_inline = (tokens, idx, _, __, slf) =>
|
||||
`<code${slf.renderAttrs(tokens[idx])}>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code>`;
|
||||
|
||||
md.renderer.rules.code_block = (tokens, idx, _, __, slf) =>
|
||||
`<pre${slf.renderAttrs(tokens[idx])}><code>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code></pre>\n`;
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, _, slf) => {
|
||||
const token = tokens[idx];
|
||||
const info = token.info ? unescapeAll(token.info).trim() : '';
|
||||
let langName = '';
|
||||
let langAttrs = '';
|
||||
|
||||
if (info) {
|
||||
const arr = info.split(/(\s+)/g);
|
||||
langName = arr[0];
|
||||
langAttrs = arr.slice(2).join('');
|
||||
}
|
||||
|
||||
const highlighted =
|
||||
options.highlight?.(token.content, langName, langAttrs) ??
|
||||
createHtmlFragmentWithSearchHighlight(token.content, search);
|
||||
|
||||
if (highlighted.indexOf('<pre') === 0) {
|
||||
return highlighted + '\n';
|
||||
}
|
||||
|
||||
return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,29 @@ describe('RunDataParsedAiContent', () => {
|
||||
expect(rendered.getByText('markdown', { selector: 'em' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlight matches to the search keyword in markdown', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
content: [
|
||||
{
|
||||
raw: {},
|
||||
parsedContent: {
|
||||
type: 'markdown',
|
||||
data: 'The **quick** brown fox jumps over the ~~lazy~~ dog',
|
||||
parsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
search: 'the',
|
||||
});
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
|
||||
expect(marks).toHaveLength(2);
|
||||
expect(marks[0]).toHaveTextContent('The');
|
||||
expect(marks[1]).toHaveTextContent('the');
|
||||
});
|
||||
|
||||
it('renders AI content parsed as JSON', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
@@ -71,4 +94,83 @@ describe('RunDataParsedAiContent', () => {
|
||||
rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlight matches to the search keyword in inline code in markdown', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
content: [
|
||||
{
|
||||
raw: {},
|
||||
parsedContent: {
|
||||
type: 'markdown',
|
||||
data: 'The `quick brown fox` jumps over the lazy dog',
|
||||
parsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
search: 'fox',
|
||||
});
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0]).toHaveTextContent('fox');
|
||||
});
|
||||
|
||||
it('highlight matches to the search keyword in a code block in markdown', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
content: [
|
||||
{
|
||||
raw: {},
|
||||
parsedContent: {
|
||||
type: 'markdown',
|
||||
data: 'Code:\n\n quickFox.jump({ over: lazyDog });\n',
|
||||
parsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
search: 'fox',
|
||||
});
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0]).toHaveTextContent('Fox');
|
||||
});
|
||||
|
||||
it('highlight matches to the search keyword in fence syntax in markdown', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
content: [
|
||||
{
|
||||
raw: {},
|
||||
parsedContent: {
|
||||
type: 'markdown',
|
||||
data: '```\nquickFox.jump({ over: lazyDog });\n```',
|
||||
parsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
search: 'fox',
|
||||
});
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0]).toHaveTextContent('Fox');
|
||||
});
|
||||
|
||||
it('highlight matches to the search keyword in raw data', () => {
|
||||
const rendered = renderComponent({
|
||||
renderType: 'rendered',
|
||||
content: [{ raw: { key: 'value' }, parsedContent: null }],
|
||||
search: 'key',
|
||||
});
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0]).toHaveTextContent('key');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,18 @@ import { N8nIconButton } from '@n8n/design-system';
|
||||
import { type IDataObject } from 'n8n-workflow';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import { computed } from 'vue';
|
||||
import { createSearchHighlightPlugin } from '@/components/RunDataAi/utils';
|
||||
|
||||
const {
|
||||
content,
|
||||
compact = false,
|
||||
renderType,
|
||||
search,
|
||||
} = defineProps<{
|
||||
content: ParsedAiContent;
|
||||
compact?: boolean;
|
||||
search?: string;
|
||||
renderType: 'rendered' | 'json';
|
||||
}>();
|
||||
|
||||
@@ -22,6 +26,8 @@ const i18n = useI18n();
|
||||
const clipboard = useClipboard();
|
||||
const { showMessage } = useToast();
|
||||
|
||||
const vueMarkdownPlugins = computed(() => [createSearchHighlightPlugin(search)]);
|
||||
|
||||
function isJsonString(text: string) {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
@@ -39,7 +45,7 @@ const markdownOptions = {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return ''; // use external default escaping
|
||||
return undefined; // use external default escaping
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,17 +114,20 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
|
||||
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
||||
:class="$style.markdown"
|
||||
:options="markdownOptions"
|
||||
:plugins="vueMarkdownPlugins"
|
||||
/>
|
||||
<VueMarkdown
|
||||
v-else-if="parsedContent.type === 'markdown'"
|
||||
:source="parsedContent.data"
|
||||
:class="$style.markdown"
|
||||
:options="markdownOptions"
|
||||
:plugins="vueMarkdownPlugins"
|
||||
/>
|
||||
<p
|
||||
<TextWithHighlights
|
||||
v-else-if="parsedContent.type === 'text'"
|
||||
:class="$style.runText"
|
||||
v-text="parsedContent.data"
|
||||
:content="String(parsedContent.data)"
|
||||
:search="search"
|
||||
/>
|
||||
</template>
|
||||
<!-- We weren't able to parse text or raw switch -->
|
||||
@@ -131,7 +140,11 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
|
||||
icon="files"
|
||||
@click="onCopyToClipboard(raw)"
|
||||
/>
|
||||
<VueMarkdown :source="jsonToMarkdown(raw as JsonMarkdown)" :class="$style.markdown" />
|
||||
<VueMarkdown
|
||||
:source="jsonToMarkdown(raw as JsonMarkdown)"
|
||||
:class="$style.markdown"
|
||||
:plugins="vueMarkdownPlugins"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { splitTextBySearch } from '@/utils/stringUtils';
|
||||
import type { GenericValue } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -7,26 +8,6 @@ const props = defineProps<{
|
||||
search?: string;
|
||||
}>();
|
||||
|
||||
const splitTextBySearch = (
|
||||
text = '',
|
||||
search = '',
|
||||
): Array<{ tag: 'span' | 'mark'; content: string }> => {
|
||||
if (!search) {
|
||||
return [
|
||||
{
|
||||
tag: 'span',
|
||||
content: text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
|
||||
const splitText = text.split(pattern);
|
||||
|
||||
return splitText.map((t) => ({ tag: pattern.test(t) ? 'mark' : 'span', content: t }));
|
||||
};
|
||||
|
||||
const parts = computed(() => {
|
||||
return props.search && typeof props.content === 'string'
|
||||
? splitTextBySearch(props.content, props.search)
|
||||
@@ -37,9 +18,9 @@ const parts = computed(() => {
|
||||
<template>
|
||||
<span v-if="parts.length && typeof props.content === 'string'">
|
||||
<template v-for="(part, index) in parts">
|
||||
<mark v-if="part.tag === 'mark' && part.content" :key="`mark-${index}`">{{
|
||||
part.content
|
||||
}}</mark>
|
||||
<mark v-if="part.isMatched && part.content" :key="`mark-${index}`">
|
||||
{{ part.content }}
|
||||
</mark>
|
||||
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
@@ -2762,17 +2762,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
|
||||
>
|
||||
|
||||
|
||||
<!--v-if-->
|
||||
|
||||
|
||||
<mark>
|
||||
John
|
||||
</mark>
|
||||
|
||||
|
||||
<!--v-if-->
|
||||
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2900,17 +2894,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
|
||||
>
|
||||
|
||||
|
||||
<!--v-if-->
|
||||
|
||||
|
||||
<mark>
|
||||
John
|
||||
</mark>
|
||||
|
||||
|
||||
<!--v-if-->
|
||||
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
35
packages/frontend/editor-ui/src/utils/stringUtils.test.ts
Normal file
35
packages/frontend/editor-ui/src/utils/stringUtils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { splitTextBySearch } from './stringUtils';
|
||||
|
||||
describe(splitTextBySearch, () => {
|
||||
it('should return one element with isMatched=false if search text is empty', () => {
|
||||
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', '')).toEqual([
|
||||
{ isMatched: false, content: 'The quick brown fox jumps over the lazy dog' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should split given text by matches to the search', () => {
|
||||
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'quick')).toEqual([
|
||||
{ isMatched: false, content: 'The ' },
|
||||
{ isMatched: true, content: 'quick' },
|
||||
{ isMatched: false, content: ' brown fox jumps over the lazy dog' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should match case insensitive', () => {
|
||||
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'Quick')).toEqual([
|
||||
{ isMatched: false, content: 'The ' },
|
||||
{ isMatched: true, content: 'quick' },
|
||||
{ isMatched: false, content: ' brown fox jumps over the lazy dog' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should match all occurrences', () => {
|
||||
expect(splitTextBySearch('The quick brown fox jumps over the lazy dog', 'the')).toEqual([
|
||||
{ isMatched: true, content: 'The' },
|
||||
{ isMatched: false, content: ' quick brown fox jumps over ' },
|
||||
{ isMatched: true, content: 'the' },
|
||||
{ isMatched: false, content: ' lazy dog' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
23
packages/frontend/editor-ui/src/utils/stringUtils.ts
Normal file
23
packages/frontend/editor-ui/src/utils/stringUtils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Split given text by the search term
|
||||
*
|
||||
* @param text Text to split
|
||||
* @param search The search term
|
||||
* @returns An array containing splitted text, each containing text fragment and the match flag.
|
||||
*/
|
||||
export function splitTextBySearch(
|
||||
text: string,
|
||||
search: string,
|
||||
): Array<{ isMatched: boolean; content: string }> {
|
||||
if (!search) {
|
||||
return [{ isMatched: false, content: text }];
|
||||
}
|
||||
|
||||
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
const pattern = new RegExp(`(${escapeRegExp(search)})`, 'i');
|
||||
|
||||
return text
|
||||
.split(pattern)
|
||||
.map((part) => ({ isMatched: pattern.test(part), content: part }))
|
||||
.filter((part) => part.content !== '');
|
||||
}
|
||||
Reference in New Issue
Block a user