mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
fix(editor): Avoid sanitizing output to search node data (#8126)
## Summary In search feature, output sanitization was added to support `<mark` tag in output panel to highlight searched text. This removes any html like data in the input/output panel.. This PR removes sanitization while keeping text highlights.. ## Related tickets and issues https://community.n8n.io/t/n8n-output/33997 https://community.n8n.io/t/html-tags-in-editor-rendered/34240 https://github.com/n8n-io/n8n/issues/8081 https://linear.app/n8n/issue/ADO-1594/node-output-view-not-consistent https://linear.app/n8n/issue/ADO-1597/bug-xml-display-issue ## Review / Merge checklist - [X] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests.
This commit is contained in:
parent
e928210ccd
commit
c83d9f45ba
|
@ -471,7 +471,8 @@ describe('NDV', () => {
|
|||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
||||
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
it('should show node name and version in settings', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`);
|
||||
|
||||
|
@ -490,6 +491,34 @@ describe('NDV', () => {
|
|||
ndv.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)');
|
||||
ndv.actions.close();
|
||||
});
|
||||
|
||||
it('Should render xml and html tags as strings and can search', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_xml_output.json', `test`);
|
||||
|
||||
workflowPage.actions.executeWorkflow();
|
||||
|
||||
workflowPage.actions.openNode('Edit Fields');
|
||||
|
||||
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table');
|
||||
|
||||
ndv.getters.outputTableRow(1).should('include.text', '<?xml version="1.0" encoding="UTF-8"?> <library>');
|
||||
|
||||
cy.document().trigger('keyup', { key: '/' });
|
||||
ndv.getters.searchInput().filter(':focus').type('<lib');
|
||||
|
||||
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib')
|
||||
|
||||
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
|
||||
ndv.getters.outputDisplayMode().find('label').eq(1).click();
|
||||
|
||||
ndv.getters.outputDataContainer().should('have.text', '[{"body": "<?xml version="1.0" encoding="UTF-8"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"}]');
|
||||
ndv.getters.outputDataContainer().find('mark').should('have.text', '<lib')
|
||||
|
||||
ndv.getters.outputDisplayMode().find('label').eq(2).should('include.text', 'Schema');
|
||||
ndv.getters.outputDisplayMode().find('label').eq(2).click({force: true});
|
||||
ndv.getters.outputDataContainer().findChildByTestId('run-data-schema-item').find('> span').should('include.text', '<?xml version="1.0" encoding="UTF-8"?>');
|
||||
});
|
||||
|
||||
it('should properly show node execution indicator', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||
workflowPage.actions.openNode('Code');
|
||||
|
@ -499,6 +528,7 @@ describe('NDV', () => {
|
|||
ndv.getters.nodeExecuteButton().click();
|
||||
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||
});
|
||||
|
||||
it('should properly show node execution indicator for multiple nodes', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Code');
|
||||
workflowPage.actions.openNode('Code');
|
||||
|
@ -513,6 +543,7 @@ describe('NDV', () => {
|
|||
workflowPage.actions.openNode('Code');
|
||||
ndv.getters.nodeRunErrorIndicator().should('exist');
|
||||
});
|
||||
|
||||
it('Should handle mismatched option attributes', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
|
||||
// Add some attributes in Create operation
|
||||
|
@ -521,6 +552,7 @@ describe('NDV', () => {
|
|||
// Attributes should be empty after operation change
|
||||
cy.getByTestId('parameter-item').contains('Currently no items exist').should('exist');
|
||||
});
|
||||
|
||||
it('Should keep RLC values after operation change', () => {
|
||||
const TEST_DOC_ID = '1111';
|
||||
workflowPage.actions.addInitialNodeToCanvas('Google Sheets', { keepNdvOpen: true, action: 'Append row in sheet' });
|
||||
|
|
53
cypress/fixtures/Test_workflow_xml_output.json
Normal file
53
cypress/fixtures/Test_workflow_xml_output.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"meta": {
|
||||
"instanceId": "2d1cf27f75b18bb9e146336f791c37884f4fc7ddb97c2def27c0444d106778bf"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "8108d313-8b03-4aa4-963d-cd1c0fe8f85c",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
420,
|
||||
220
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fields": {
|
||||
"values": [
|
||||
{
|
||||
"name": "body",
|
||||
"stringValue": "<?xml version=\"1.0\" encoding=\"UTF-8\"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "45888152-7c5f-4d88-9039-660c594da084",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
640,
|
||||
220
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
|
@ -33,7 +33,9 @@
|
|||
@update:selectedValue="selectedJsonPath = $event"
|
||||
>
|
||||
<template #renderNodeKey="{ node }">
|
||||
<span
|
||||
<TextWithHighlights
|
||||
:content="getContent(node.key)"
|
||||
:search="search"
|
||||
data-target="mappable"
|
||||
:data-value="getJsonParameterPath(node.path)"
|
||||
:data-name="node.key"
|
||||
|
@ -43,13 +45,18 @@
|
|||
[$style.mappable]: mappingEnabled,
|
||||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
v-html="highlightSearchTerm(node.key)"
|
||||
/>
|
||||
</template>
|
||||
<template #renderNodeValue="{ node }">
|
||||
<span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
|
||||
<span
|
||||
<TextWithHighlights
|
||||
v-if="isNaN(node.index)"
|
||||
:content="getContent(node.content)"
|
||||
:search="search"
|
||||
/>
|
||||
<TextWithHighlights
|
||||
v-else
|
||||
:content="getContent(node.content)"
|
||||
:search="search"
|
||||
data-target="mappable"
|
||||
:data-value="getJsonParameterPath(node.path)"
|
||||
:data-name="getListItemName(node.path)"
|
||||
|
@ -60,7 +67,6 @@
|
|||
[$style.dragged]: draggingPath === node.path,
|
||||
}"
|
||||
class="ph-no-capture"
|
||||
v-html="highlightSearchTerm(node.content)"
|
||||
/>
|
||||
</template>
|
||||
</vue-json-pretty>
|
||||
|
@ -76,7 +82,6 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
|||
import Draggable from '@/components/Draggable.vue';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { isString } from '@/utils/typeGuards';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { mapStores } from 'pinia';
|
||||
|
@ -86,6 +91,7 @@ import { getMappedExpression } from '@/utils/mappingUtils';
|
|||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { nonExistingJsonPath } from '@/constants';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
const RunDataJsonActions = defineAsyncComponent(
|
||||
async () => import('@/components/RunDataJsonActions.vue'),
|
||||
|
@ -98,6 +104,7 @@ export default defineComponent({
|
|||
Draggable,
|
||||
RunDataJsonActions,
|
||||
MappingPill,
|
||||
TextWithHighlights,
|
||||
},
|
||||
props: {
|
||||
editMode: {
|
||||
|
@ -202,9 +209,6 @@ export default defineComponent({
|
|||
getListItemName(path: string): string {
|
||||
return path.replace(/^(\["?\d"?]\.?)/g, '');
|
||||
},
|
||||
highlightSearchTerm(value: string): string {
|
||||
return sanitizeHtml(highlightText(this.getContent(value), this.search));
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { INodeUi, Schema } from '@/Interface';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { checkExhaustive } from '@/utils/typeGuards';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
type Props = {
|
||||
schema: Schema;
|
||||
|
@ -30,12 +30,8 @@ const isFlat = computed(
|
|||
props.schema.value.every((v) => !Array.isArray(v.value)),
|
||||
);
|
||||
const key = computed((): string | undefined => {
|
||||
const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
|
||||
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
|
||||
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
|
||||
});
|
||||
const parentKey = computed((): string | undefined =>
|
||||
sanitizeHtml(highlightText(props.parent.key, props.search)),
|
||||
);
|
||||
const schemaName = computed(() =>
|
||||
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
|
||||
);
|
||||
|
@ -99,8 +95,17 @@ const getIconBySchemaType = (type: Schema['type']): string => {
|
|||
data-target="mappable"
|
||||
>
|
||||
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
|
||||
<span v-if="isSchemaParentTypeArray" v-html="parentKey" />
|
||||
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
|
||||
<TextWithHighlights
|
||||
v-if="isSchemaParentTypeArray"
|
||||
:content="props.parent?.key"
|
||||
:search="props.search"
|
||||
/>
|
||||
<TextWithHighlights
|
||||
v-if="key"
|
||||
:class="{ [$style.arrayIndex]: isSchemaParentTypeArray }"
|
||||
:content="key"
|
||||
:search="props.search"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="text" :class="$style.text">{{ text }}</span>
|
||||
|
|
|
@ -52,7 +52,10 @@
|
|||
[$style.draggingHeader]: isDragging,
|
||||
}"
|
||||
>
|
||||
<span v-html="highlightSearchTerm(column || '')" />
|
||||
<TextWithHighlights
|
||||
:content="getValueToRender(column || '')"
|
||||
:search="search"
|
||||
/>
|
||||
<div :class="$style.dragButton">
|
||||
<font-awesome-icon icon="grip-vertical" />
|
||||
</div>
|
||||
|
@ -117,10 +120,11 @@
|
|||
@mouseleave="onMouseLeaveCell"
|
||||
:class="hasJsonInColumn(index2) ? $style.minColWidth : $style.limitColWidth"
|
||||
>
|
||||
<span
|
||||
<TextWithHighlights
|
||||
v-if="isSimple(data)"
|
||||
:content="getValueToRender(data)"
|
||||
:search="search"
|
||||
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
|
||||
v-html="highlightSearchTerm(data)"
|
||||
/>
|
||||
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
|
||||
<template #label="{ label, path }">
|
||||
|
@ -141,9 +145,10 @@
|
|||
>
|
||||
</template>
|
||||
<template #value="{ value }">
|
||||
<span
|
||||
<TextWithHighlights
|
||||
:content="getValueToRender(value)"
|
||||
:search="search"
|
||||
:class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
|
||||
v-html="highlightSearchTerm(value)"
|
||||
/>
|
||||
</template>
|
||||
</n8n-tree>
|
||||
|
@ -162,7 +167,6 @@ import type { PropType } from 'vue';
|
|||
import { mapStores } from 'pinia';
|
||||
import type { INodeUi, ITableData, NDVState } from '@/Interface';
|
||||
import { shorten } from '@/utils/typesUtils';
|
||||
import { highlightText, sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { getPairedItemId } from '@/utils/pairedItemUtils';
|
||||
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import Draggable from './Draggable.vue';
|
||||
|
@ -171,6 +175,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||
import MappingPill from './MappingPill.vue';
|
||||
import { getMappedExpression } from '@/utils/mappingUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
|
||||
const MAX_COLUMNS_LIMIT = 40;
|
||||
|
||||
|
@ -178,7 +183,7 @@ type DraggableRef = InstanceType<typeof Draggable>;
|
|||
|
||||
export default defineComponent({
|
||||
name: 'run-data-table',
|
||||
components: { Draggable, MappingPill },
|
||||
components: { Draggable, MappingPill, TextWithHighlights },
|
||||
props: {
|
||||
node: {
|
||||
type: Object as PropType<INodeUi>,
|
||||
|
@ -392,9 +397,6 @@ export default defineComponent({
|
|||
}
|
||||
return value;
|
||||
},
|
||||
highlightSearchTerm(value: string): string {
|
||||
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
|
||||
},
|
||||
onDragStart() {
|
||||
this.draggedColumn = true;
|
||||
this.ndvStore.resetMappingTelemetry();
|
||||
|
|
52
packages/editor-ui/src/components/TextWithHighlights.vue
Normal file
52
packages/editor-ui/src/components/TextWithHighlights.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { GenericValue } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: [Object, String, Number] as PropType<GenericValue>,
|
||||
},
|
||||
search: {
|
||||
type: 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)
|
||||
: [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<span v-else-if="part.content" :key="`span-${index}`">{{ part.content }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else>{{ props.content }}</span>
|
||||
</template>
|
|
@ -0,0 +1,103 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import TextWithHighlights from '@/components/TextWithHighlights.vue';
|
||||
|
||||
describe('TextWithHighlights', () => {
|
||||
it('highlights the search text in the content', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content',
|
||||
search: 'Test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<mark>Test</mark>');
|
||||
expect(wrapper.html()).toContain('<span> content</span>');
|
||||
});
|
||||
|
||||
it('renders correctly when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>Test content</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly numbers when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>1</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly objects when search is not set', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: { hello: 'world' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('renders correctly objects ignoring search', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: { hello: 'world' },
|
||||
search: 'yo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual('<span>{\n "hello": "world"\n}</span>');
|
||||
expect(wrapper.html()).not.toContain('<mark>');
|
||||
});
|
||||
|
||||
it('highlights the search text in middle of the content', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content hello world',
|
||||
search: 'con',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
'<span><span>Test </span><mark>con</mark><span>tent hello world</span></span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles special regex characters in search correctly', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
content: 'Test content (hello) world',
|
||||
search: '(hello)',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
'<span><span>Test content </span><mark>(hello)</mark><span> world</span></span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('searches for special regex characters correctly', () => {
|
||||
const wrapper = shallowMount(TextWithHighlights, {
|
||||
props: {
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
content: 'Test content ()^${}[] world',
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
search: '()^${}[]',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toEqual(
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
'<span><span>Test content </span><mark>()^${}[]</mark><span> world</span></span>',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,41 +0,0 @@
|
|||
import { highlightText } from '@/utils/htmlUtils';
|
||||
|
||||
describe('highlightText', () => {
|
||||
it('should return original text if search parameter is an empty string', () => {
|
||||
const text = 'some text';
|
||||
const result = highlightText(text);
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should return original text if it is an empty string', () => {
|
||||
const text = '';
|
||||
const result = highlightText(text, 'search');
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should escape special characters in the search string', () => {
|
||||
const text = 'some text [example]';
|
||||
const result = highlightText(text, '[example]');
|
||||
expect(result).toBe('some text <mark class="highlight">[example]</mark>');
|
||||
});
|
||||
|
||||
it('should escape other special characters in the search string', () => {
|
||||
const text = 'phone number: +123-456-7890';
|
||||
const result = highlightText(text, '+123-456-7890');
|
||||
expect(result).toBe('phone number: <mark class="highlight">+123-456-7890</mark>');
|
||||
});
|
||||
|
||||
it('should highlight occurrences of the search string in text', () => {
|
||||
const text = 'example text example';
|
||||
const result = highlightText(text, 'example');
|
||||
expect(result).toBe(
|
||||
'<mark class="highlight">example</mark> text <mark class="highlight">example</mark>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original text if the search string is not found', () => {
|
||||
const text = 'some text';
|
||||
const result = highlightText(text, 'notfound');
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
});
|
|
@ -63,9 +63,3 @@ export const getBannerRowHeight = async (): Promise<number> => {
|
|||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
export const highlightText = (text: string, search = ''): string => {
|
||||
const pattern = search.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
|
||||
const regex = new RegExp(`(${pattern})`, 'gi');
|
||||
return search ? text?.replace(regex, '<mark class="highlight">$1</mark>') : text;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue