mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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>
|
||||||
|
|
||||||
<Suspense v-else-if="hasNodeRun && displayMode === 'ai'">
|
<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>
|
||||||
|
|
||||||
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">
|
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
type NodeConnectionType,
|
type NodeConnectionType,
|
||||||
type Workflow,
|
type Workflow,
|
||||||
} from 'n8n-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 {
|
export interface AIResult {
|
||||||
node: string;
|
node: string;
|
||||||
@@ -198,3 +202,52 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
|
|||||||
|
|
||||||
return tokenUsage;
|
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();
|
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', () => {
|
it('renders AI content parsed as JSON', () => {
|
||||||
const rendered = renderComponent({
|
const rendered = renderComponent({
|
||||||
renderType: 'rendered',
|
renderType: 'rendered',
|
||||||
@@ -71,4 +94,83 @@ describe('RunDataParsedAiContent', () => {
|
|||||||
rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }),
|
rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }),
|
||||||
).toBeInTheDocument();
|
).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 { type IDataObject } from 'n8n-workflow';
|
||||||
import VueMarkdown from 'vue-markdown-render';
|
import VueMarkdown from 'vue-markdown-render';
|
||||||
import hljs from 'highlight.js/lib/core';
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { createSearchHighlightPlugin } from '@/components/RunDataAi/utils';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
compact = false,
|
compact = false,
|
||||||
renderType,
|
renderType,
|
||||||
|
search,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
content: ParsedAiContent;
|
content: ParsedAiContent;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
search?: string;
|
||||||
renderType: 'rendered' | 'json';
|
renderType: 'rendered' | 'json';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ const i18n = useI18n();
|
|||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const { showMessage } = useToast();
|
const { showMessage } = useToast();
|
||||||
|
|
||||||
|
const vueMarkdownPlugins = computed(() => [createSearchHighlightPlugin(search)]);
|
||||||
|
|
||||||
function isJsonString(text: string) {
|
function isJsonString(text: string) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(text);
|
JSON.parse(text);
|
||||||
@@ -39,7 +45,7 @@ const markdownOptions = {
|
|||||||
} catch {}
|
} 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)"
|
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
||||||
:class="$style.markdown"
|
:class="$style.markdown"
|
||||||
:options="markdownOptions"
|
:options="markdownOptions"
|
||||||
|
:plugins="vueMarkdownPlugins"
|
||||||
/>
|
/>
|
||||||
<VueMarkdown
|
<VueMarkdown
|
||||||
v-else-if="parsedContent.type === 'markdown'"
|
v-else-if="parsedContent.type === 'markdown'"
|
||||||
:source="parsedContent.data"
|
:source="parsedContent.data"
|
||||||
:class="$style.markdown"
|
:class="$style.markdown"
|
||||||
:options="markdownOptions"
|
:options="markdownOptions"
|
||||||
|
:plugins="vueMarkdownPlugins"
|
||||||
/>
|
/>
|
||||||
<p
|
<TextWithHighlights
|
||||||
v-else-if="parsedContent.type === 'text'"
|
v-else-if="parsedContent.type === 'text'"
|
||||||
:class="$style.runText"
|
:class="$style.runText"
|
||||||
v-text="parsedContent.data"
|
:content="String(parsedContent.data)"
|
||||||
|
:search="search"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- We weren't able to parse text or raw switch -->
|
<!-- We weren't able to parse text or raw switch -->
|
||||||
@@ -131,7 +140,11 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
|
|||||||
icon="files"
|
icon="files"
|
||||||
@click="onCopyToClipboard(raw)"
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { splitTextBySearch } from '@/utils/stringUtils';
|
||||||
import type { GenericValue } from 'n8n-workflow';
|
import type { GenericValue } from 'n8n-workflow';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
@@ -7,26 +8,6 @@ const props = defineProps<{
|
|||||||
search?: string;
|
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(() => {
|
const parts = computed(() => {
|
||||||
return props.search && typeof props.content === 'string'
|
return props.search && typeof props.content === 'string'
|
||||||
? splitTextBySearch(props.content, props.search)
|
? splitTextBySearch(props.content, props.search)
|
||||||
@@ -37,9 +18,9 @@ const parts = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<span v-if="parts.length && typeof props.content === 'string'">
|
<span v-if="parts.length && typeof props.content === 'string'">
|
||||||
<template v-for="(part, index) in parts">
|
<template v-for="(part, index) in parts">
|
||||||
<mark v-if="part.tag === 'mark' && part.content" :key="`mark-${index}`">{{
|
<mark v-if="part.isMatched && part.content" :key="`mark-${index}`">
|
||||||
part.content
|
{{ part.content }}
|
||||||
}}</mark>
|
</mark>
|
||||||
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
|
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2762,17 +2762,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
|
|||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
|
|
||||||
|
|
||||||
<mark>
|
<mark>
|
||||||
John
|
John
|
||||||
</mark>
|
</mark>
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2900,17 +2894,11 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
|
|||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
|
|
||||||
|
|
||||||
<mark>
|
<mark>
|
||||||
John
|
John
|
||||||
</mark>
|
</mark>
|
||||||
|
|
||||||
|
|
||||||
<!--v-if-->
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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